From 10f71fce049612d9f540aece004c2967c6cbc853 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 5 Sep 2026 21:31:26 -0700 Subject: [PATCH 1/6] fix(server): make provider inventory refresh outcomes explicit --- .../threads/use-composer-command-menu.ts | 15 +- .../src/provider/Drivers/ClaudeDriver.ts | 22 +- .../src/provider/Drivers/ClaudeSkills.test.ts | 30 +- .../src/provider/Drivers/ClaudeSkills.ts | 50 +- .../src/provider/Drivers/CodexDriver.ts | 10 +- .../src/provider/Drivers/CursorDriver.ts | 12 +- .../server/src/provider/Drivers/GrokDriver.ts | 12 +- .../src/provider/Drivers/OpenCodeDriver.ts | 5 + .../Layers/AntigravityProvider.test.ts | 14 + .../provider/Layers/AntigravityProvider.ts | 96 ++- .../src/provider/Layers/ClaudeAdapter.ts | 2 + .../src/provider/Layers/ClaudeProvider.ts | 29 +- .../src/provider/Layers/CodexProvider.test.ts | 70 +- .../src/provider/Layers/CodexProvider.ts | 53 +- .../provider/Layers/CursorProvider.test.ts | 14 + .../src/provider/Layers/CursorProvider.ts | 22 +- .../src/provider/Layers/GrokProvider.ts | 34 +- .../provider/Layers/OpenCodeProvider.test.ts | 37 +- .../src/provider/Layers/OpenCodeProvider.ts | 12 + .../provider/Layers/ProviderRegistry.test.ts | 693 +++++------------- .../src/provider/Layers/ProviderRegistry.ts | 131 ++-- .../opencodeRuntime.cliParsers.test.ts | 5 +- .../opencodeRuntime.inventory.test.ts | 4 +- apps/server/src/provider/opencodeRuntime.ts | 47 +- apps/server/src/provider/providerSnapshot.ts | 76 ++ .../src/provider/providerStatusCache.test.ts | 17 + .../src/provider/providerStatusCache.ts | 30 +- .../provider/unavailableProviderSnapshot.ts | 3 +- apps/web/src/components/chat/ChatComposer.tsx | 19 +- .../client-runtime/src/providerSkills.test.ts | 54 +- packages/client-runtime/src/providerSkills.ts | 10 +- packages/contracts/src/server.ts | 36 + 32 files changed, 921 insertions(+), 743 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 23c56d28f4c4..755710d02106 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -1,3 +1,4 @@ +import { hasProviderWorkspaceSkills } from "@t3tools/contracts"; import type { EnvironmentId, ProviderInteractionMode, ServerProvider } from "@t3tools/contracts"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { @@ -203,10 +204,7 @@ export function useComposerCommandMenu({ reportFailure: false, }); const selectedProviderInstanceId = selectedProviderStatus?.instanceId; - const hasWorkspaceSnapshot = Boolean( - projectCwd && - selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd), - ); + const hasWorkspaceSnapshot = hasProviderWorkspaceSkills(selectedProviderStatus, projectCwd); const workspaceRefreshKeyRef = useRef(null); const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); const hadWorkspaceSnapshotRef = useRef(false); @@ -243,9 +241,12 @@ export function useComposerCommandMenu({ }).then((result) => { const refreshed = result._tag === "Success" && - result.value.providers - .find((provider) => provider.instanceId === selectedProviderInstanceId) - ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === projectCwd); + hasProviderWorkspaceSkills( + result.value.providers.find( + (provider) => provider.instanceId === selectedProviderInstanceId, + ), + projectCwd, + ); if (!refreshed && workspaceRefreshKeyRef.current === key) { retryLater(); } diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 324284a3a4c7..7feeb5c4dc60 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -39,6 +39,7 @@ import { import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { STALE_PROVIDER_INVENTORY } from "../providerSnapshot.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { defaultProviderContinuationIdentity, @@ -236,9 +237,26 @@ export const ClaudeDriver: ProviderDriver = { ? snapshot.getSnapshot : Effect.all([ snapshot.getSnapshot, - discoverClaudeSkills(effectiveConfig, cwd, processEnv), + discoverClaudeSkills(effectiveConfig, cwd, processEnv).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to discover Claude skills for '${cwd}'`, + cause, + }), + ), + ), ]).pipe( - Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), + Effect.map(([machineSnapshot, skills]) => ({ + ...machineSnapshot, + skills, + inventory: { + ...(machineSnapshot.inventory ?? STALE_PROVIDER_INVENTORY), + skills: "authoritative" as const, + }, + })), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index d126a15c12b8..46fa27a1a049 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -4,6 +4,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts"; @@ -20,6 +21,33 @@ const writeSkill = Effect.fn(function* ( }); it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { + it.effect("does not treat unreadable skill files or roots as an empty inventory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-claude-skill-read-failure-", + }); + yield* writeSkill(path.join(configDir, "skills"), "review", "# Review changes"); + + for (const method of ["readDirectory", "readFileString"] as const) { + const failure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method, + }); + const error = yield* discoverClaudeSkills({ homePath: configDir }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + [method]: () => Effect.fail(failure), + }), + Effect.flip, + ); + assert.strictEqual(error, failure); + } + }), + ); + it.effect("discovers user and project skills with frontmatter metadata", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -331,7 +359,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); - it.effect("ignores unreadable settings when resolving skillOverrides", () => + it.effect("ignores malformed settings when resolving skillOverrides", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 236fe79f518c..787ad445f25d 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -19,6 +19,7 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; @@ -26,6 +27,11 @@ import { parse as parseYamlDocument } from "yaml"; import { expandHomePath } from "../../pathExpansion.ts"; +const readIfPresent = (effect: Effect.Effect) => + effect.pipe( + Effect.catch((error) => (error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error))), + ); + type ClaudeSkillScope = "user" | "project"; const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; @@ -161,14 +167,16 @@ export function skillOverrideSettingsPaths( */ const findRepositoryRoot = Effect.fn("findRepositoryRoot")(function* ( cwd: string, -): Effect.fn.Return { +): Effect.fn.Return< + string | undefined, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path +> { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; let current = path.resolve(cwd); while (true) { - const isRoot = yield* fileSystem - .exists(path.join(current, ".git")) - .pipe(Effect.orElseSucceed(() => false)); + const isRoot = yield* fileSystem.exists(path.join(current, ".git")); if (isRoot) { return current; } @@ -223,7 +231,11 @@ const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( configDirPath: string, cwd: string | undefined, environment: NodeJS.ProcessEnv, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { +): Effect.fn.Return< + ReadonlyMap, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path +> { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const platform = yield* HostProcessPlatform; @@ -238,9 +250,7 @@ const readSkillOverrides = Effect.fn("readSkillOverrides")(function* ( environment, repositoryRoot, )) { - const contents = yield* fileSystem - .readFileString(settingsPath) - .pipe(Effect.orElseSucceed(() => undefined)); + const contents = yield* readIfPresent(fileSystem.readFileString(settingsPath)); if (contents === undefined) { continue; } @@ -297,9 +307,9 @@ const resolveClaudeConfigDirPath = Effect.fn("resolveClaudeConfigDirPath")(funct /** * Enumerate Claude Code skills from the user config dir and the workspace - * `.claude/skills`. Discovery is best-effort: unreadable roots and malformed - * skill entries are skipped so a broken skill never degrades the provider - * snapshot. Roots are listed highest precedence first and the first hit for a + * `.claude/skills`. Missing roots and malformed skill entries are skipped. + * Read failures stay failures so a refresh cannot cache an incomplete list. + * Roots are listed highest precedence first and the first hit for a * name wins, matching Claude Code: verified against the CLI with the same * skill name in both scopes, the user copy is the one that runs. Reporting the * project copy instead would attach its invocation metadata to a command @@ -309,7 +319,11 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* config: Pick, cwd?: string, environment?: NodeJS.ProcessEnv, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { +): Effect.fn.Return< + ReadonlyArray, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path +> { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const configDirPath = yield* resolveClaudeConfigDirPath(config, environment ?? process.env, cwd); @@ -322,15 +336,15 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* const skillsByName = new Map(); for (const root of roots) { - const entries = yield* fileSystem - .readDirectory(root.directory) - .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + const entries = (yield* readIfPresent(fileSystem.readDirectory(root.directory))) ?? []; for (const entry of [...entries].sort()) { + const directory = yield* readIfPresent(fileSystem.stat(path.join(root.directory, entry))); + if (directory?.type !== "Directory") continue; const skillPath = path.join(root.directory, entry, "SKILL.md"); - const contents = yield* fileSystem - .readFileString(skillPath) - .pipe(Effect.orElseSucceed(() => undefined)); + const file = yield* readIfPresent(fileSystem.stat(skillPath)); + if (file?.type !== "File") continue; + const contents = yield* readIfPresent(fileSystem.readFileString(skillPath)); if (contents === undefined) { continue; } diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 071fb20674a8..9f29791cafd2 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -50,6 +50,7 @@ import { import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { STALE_PROVIDER_INVENTORY } from "../providerSnapshot.ts"; import * as ModelManifest from "../ModelManifest.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; @@ -259,7 +260,14 @@ export const CodexDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), ]).pipe( - Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), + Effect.map(([machineSnapshot, skills]) => ({ + ...machineSnapshot, + skills, + inventory: { + ...(machineSnapshot.inventory ?? STALE_PROVIDER_INVENTORY), + skills: "authoritative" as const, + }, + })), Effect.mapError( (cause) => new ProviderDriverError({ diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 5466af802e50..fbad6c9bc99f 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -34,6 +34,7 @@ import { } from "../Layers/CursorProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { STALE_PROVIDER_INVENTORY } from "../providerSnapshot.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -214,7 +215,16 @@ export const CursorDriver: ProviderDriver = { }), ), ), - ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ + ...machineSnapshot, + skills, + inventory: { + ...(machineSnapshot.inventory ?? STALE_PROVIDER_INVENTORY), + skills: "authoritative" as const, + }, + })), + ), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 066d0a6b63a8..ea64371b6708 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -20,6 +20,7 @@ import { } from "../Layers/GrokProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { STALE_PROVIDER_INVENTORY } from "../providerSnapshot.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -140,7 +141,16 @@ export const GrokDriver: ProviderDriver = { }), ), ), - ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))); + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ + ...machineSnapshot, + skills, + inventory: { + ...(machineSnapshot.inventory ?? STALE_PROVIDER_INVENTORY), + skills: "authoritative" as const, + }, + })), + ); return { instanceId, diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 72c1c0683de5..e0e35e2afa1f 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -34,6 +34,7 @@ import { } from "../Layers/OpenCodeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { STALE_PROVIDER_INVENTORY } from "../providerSnapshot.ts"; import { OpenCodeRuntime } from "../opencodeRuntime.ts"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import { @@ -252,6 +253,10 @@ export const OpenCodeDriver: ProviderDriver Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills: openCodeSkillsToServerProviderSkills(skills), + inventory: { + ...(machineSnapshot.inventory ?? STALE_PROVIDER_INVENTORY), + skills: "authoritative" as const, + }, })), Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts index 606ec994d461..3ff4411c2ee7 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.test.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { ANTIGRAVITY_DEFAULT_MODEL, AntigravitySettings, + hasProviderWorkspaceSkills, ProviderDriverKind, ProviderInstanceId, ProviderSetupError, @@ -630,15 +631,28 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { enabled: true, }, ]; + yield* harness.provider.onSessionStarted(started, "/workspace"); + expect( + hasProviderWorkspaceSkills(yield* harness.provider.snapshot.getSnapshot, "/workspace"), + ).toBe(false); const discovered = yield* harness.provider.snapshotForCwd("/workspace", skills); expect(discovered.skills).toEqual(skills); + yield* harness.provider.snapshot.refresh; + const afterRefresh = yield* harness.provider.snapshot.getSnapshot; + expect(hasProviderWorkspaceSkills(afterRefresh, "/workspace")).toBe(true); + expect(afterRefresh.workspaceSnapshots?.[0]?.skills).toEqual(skills); yield* harness.provider.onSessionStarted(started, "/workspace"); yield* harness.provider.onAvailableCommands(commands, "/workspace"); const after = yield* harness.provider.snapshot.getSnapshot; expect( after.workspaceSnapshots?.find((entry) => entry.cwd === "/workspace")?.skills, ).toEqual(skills); + expect(hasProviderWorkspaceSkills(after, "/workspace")).toBe(true); expect((yield* harness.provider.snapshotForCwd("/workspace")).skills).toEqual(skills); + yield* harness.provider.onAvailableCommands([], "/workspace"); + const clearedCommands = yield* harness.provider.snapshotForCwd("/workspace"); + expect(clearedCommands.slashCommands).toEqual([]); + expect(clearedCommands.skills).toEqual(skills); }), ), ); diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts index f9e082606518..3cd803e63803 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -26,6 +26,8 @@ import { } from "../providerMaintenance.ts"; import { buildServerProvider, + STALE_PROVIDER_INVENTORY, + UNAVAILABLE_PROVIDER_INVENTORY, isCommandMissingCause, withCompactionSlashCommand, type ServerProviderDraft, @@ -143,6 +145,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func checkedAt, models: [], probe: { + inventory: settings.enabled ? STALE_PROVIDER_INVENTORY : UNAVAILABLE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -163,10 +166,6 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func draft: initialDraft, authRevision: 0, }); - // Skills the driver discovered on disk per workspace. Session callbacks - // rewrite the workspace entry with native commands and must keep these, or - // the registry drops the suggestions and never re-reads the workspace. - const discoveredSkills = new Map(); const getSnapshot = SubscriptionRef.get(metadata).pipe( Effect.flatMap((state) => options.stampIdentity(state.draft)), ); @@ -216,6 +215,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func checkedAt: updatedAt, ...(missingInstallation ? { + inventory: UNAVAILABLE_PROVIDER_INVENTORY, models: [], slashCommands: [], skills: [], @@ -288,6 +288,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }, checkedAt: updatedAt, models: buildAntigravityModelsFromSession(started.sessionSetupResult), + inventory: { ...(draft.inventory ?? STALE_PROVIDER_INVENTORY), models: "authoritative" }, supportsTextGeneration, ...(cwd ? { @@ -297,7 +298,14 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func cwd, checkedAt: updatedAt, slashCommands: workspace?.slashCommands ?? draft.slashCommands, - skills: workspace?.skills ?? discoveredSkills.get(cwd) ?? [], + skills: workspace?.skills ?? [], + inventory: { + slashCommands: + workspace?.inventory?.slashCommands ?? + draft.inventory?.slashCommands ?? + "stale", + skills: workspace?.inventory?.skills ?? "stale", + }, }, ].slice(-MAX_WORKSPACE_SNAPSHOTS), } @@ -313,7 +321,17 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const models = buildAntigravityModelsFromSession({ configOptions }); yield* SubscriptionRef.update(metadata, (state) => { if (state.draft.auth.status !== "authenticated") return state; - return { ...state, draft: { ...state.draft, models } }; + return { + ...state, + draft: { + ...state.draft, + models, + inventory: { + ...(state.draft.inventory ?? STALE_PROVIDER_INVENTORY), + models: "authoritative", + }, + }, + } satisfies AntigravityProviderState; }); }); @@ -325,29 +343,36 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* SubscriptionRef.update(metadata, (state) => { if (state.draft.auth.status === "unauthenticated") return state; + const workspaces = state.draft.workspaceSnapshots ?? []; + const workspace = cwd ? workspaces.find((entry) => entry.cwd === cwd) : undefined; return { ...state, draft: { ...state.draft, slashCommands, + inventory: { + ...(state.draft.inventory ?? STALE_PROVIDER_INVENTORY), + slashCommands: "authoritative", + }, ...(cwd ? { workspaceSnapshots: [ - ...(state.draft.workspaceSnapshots ?? []).filter((entry) => entry.cwd !== cwd), + ...workspaces.filter((entry) => entry.cwd !== cwd), { cwd, checkedAt: updatedAt, slashCommands, - skills: - state.draft.workspaceSnapshots?.find((entry) => entry.cwd === cwd)?.skills ?? - discoveredSkills.get(cwd) ?? - [], + skills: workspace?.skills ?? [], + inventory: { + slashCommands: "authoritative" as const, + skills: workspace?.inventory?.skills ?? "stale", + }, }, ].slice(-MAX_WORKSPACE_SNAPSHOTS), } : {}), }, - }; + } satisfies AntigravityProviderState; }); }); @@ -364,6 +389,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func status: settings.enabled ? "warning" : "disabled", message: SIGN_IN_MESSAGE, checkedAt: updatedAt, + inventory: UNAVAILABLE_PROVIDER_INVENTORY, models: [], slashCommands: [], skills: [], @@ -372,20 +398,54 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }, }) satisfies AntigravityProviderState, ); - discoveredSkills.clear(); }); + // Disk discovery and native callbacks update the same workspace entry. const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( cwd: string, skills?: ServerProvider["skills"], ) { - if (skills) discoveredSkills.set(cwd, skills); + if (skills !== undefined) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + yield* SubscriptionRef.update(metadata, (state) => { + const workspaces = state.draft.workspaceSnapshots ?? []; + const previous = workspaces.find((entry) => entry.cwd === cwd); + const workspace = { + cwd, + checkedAt, + slashCommands: previous?.slashCommands ?? state.draft.slashCommands, + skills, + inventory: { + slashCommands: + previous?.inventory?.slashCommands ?? state.draft.inventory?.slashCommands ?? "stale", + skills: "authoritative", + }, + } satisfies NonNullable[number]; + return { + ...state, + draft: { + ...state.draft, + workspaceSnapshots: [ + ...workspaces.filter((entry) => entry.cwd !== cwd), + workspace, + ].slice(-MAX_WORKSPACE_SNAPSHOTS), + }, + }; + }); + } const snapshot = yield* getSnapshot; const workspace = snapshot.workspaceSnapshots?.find((entry) => entry.cwd === cwd); - const resolvedSkills = skills ?? workspace?.skills ?? discoveredSkills.get(cwd) ?? []; - return workspace - ? { ...snapshot, slashCommands: workspace.slashCommands, skills: resolvedSkills } - : { ...snapshot, skills: resolvedSkills }; + return { + ...snapshot, + slashCommands: workspace?.slashCommands ?? snapshot.slashCommands, + skills: workspace?.skills ?? [], + inventory: { + ...(snapshot.inventory ?? STALE_PROVIDER_INVENTORY), + slashCommands: + workspace?.inventory?.slashCommands ?? snapshot.inventory?.slashCommands ?? "stale", + skills: workspace?.inventory?.skills ?? "stale", + }, + }; }); return { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 28f571133ed1..3e1e44879859 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4973,6 +4973,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), + Effect.tapError((cause) => Effect.logDebug("Claude skill discovery failed.", { cause })), + Effect.orElseSucceed(() => []), ); const message = yield* buildUserMessageEffect(input, { fileSystem, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 5c1fdaeb37ba..a41b5249087b 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -24,6 +24,9 @@ import { import { buildServerProvider, + AUTHORITATIVE_PROVIDER_INVENTORY, + STALE_PROVIDER_INVENTORY, + UNAVAILABLE_PROVIDER_INVENTORY, DEFAULT_TIMEOUT_MS, isCommandMissingCause, parseGenericCliVersion, @@ -445,6 +448,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models: allModels, probe: { + inventory: UNAVAILABLE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -471,6 +475,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models: allModels, probe: { + inventory: isCommandMissingCause(error) + ? UNAVAILABLE_PROVIDER_INVENTORY + : STALE_PROVIDER_INVENTORY, installed: !isCommandMissingCause(error), version: null, status: "error", @@ -489,6 +496,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models: allModels, probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: true, version: null, status: "error", @@ -513,6 +521,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models: allModels, probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: true, version: parsedVersion, status: "error", @@ -532,7 +541,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) : undefined; - const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); + const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment).pipe( + Effect.tapError((cause) => Effect.logDebug("Claude skill discovery failed.", { cause })), + Effect.orElseSucceed(() => undefined), + ); const slashCommands = dedupeSlashCommands(capabilities?.slashCommands ?? []); if (!capabilities) { @@ -543,8 +555,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( models, compaction: CLAUDE_COMPACTION, slashCommands, - skills, + skills: skills ?? [], probe: { + inventory: { + ...AUTHORITATIVE_PROVIDER_INVENTORY, + skills: skills === undefined ? "stale" : "authoritative", + slashCommands: "stale", + }, installed: true, version: parsedVersion, status: "warning", @@ -574,8 +591,12 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( models, compaction: CLAUDE_COMPACTION, slashCommands, - skills, + skills: skills ?? [], probe: { + inventory: { + ...AUTHORITATIVE_PROVIDER_INVENTORY, + skills: skills === undefined ? "stale" : "authoritative", + }, installed: true, version: parsedVersion, status: "ready", @@ -611,6 +632,7 @@ export const makePendingClaudeProvider = ( checkedAt, models, probe: { + inventory: UNAVAILABLE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -626,6 +648,7 @@ export const makePendingClaudeProvider = ( checkedAt, models, probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 2aeebdb2ccd8..5c81d1c0da6f 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,74 @@ import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { CodexSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { + applyPreferredCodexDefaultModel, + checkCodexProviderStatus, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; + +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); + +it.layer(NodeServices.layer)("Codex inventory discovery", (it) => { + it.effect("keeps the successful inventory when the other request fails", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-inventory-" }); + const binaryPath = writeFakeCli({ + directory, + name: "codex-inventory", + source: [ + 'import { createInterface } from "node:readline";', + 'createInterface({ input: process.stdin }).on("line", (line) => {', + " const request = JSON.parse(line);", + " if (request.id === undefined) return;", + " const responses = {", + ' initialize: { userAgent: "codex/1.0.0", codexHome: process.cwd(), platformFamily: "unix", platformOs: "linux" },', + ' "account/read": { account: { type: "apiKey" }, requiresOpenaiAuth: false },', + ' "model/list": { data: [], nextCursor: null },', + ' "skills/list": { data: [{ cwd: process.cwd(), errors: [], skills: [] }] },', + " };", + " const result = responses[request.method];", + " const response = request.method === process.env.T3_TEST_FAILED_INVENTORY || result === undefined", + ' ? { id: request.id, error: { code: -32603, message: "Discovery failed" } }', + " : { id: request.id, result };", + ' process.stdout.write(JSON.stringify(response) + "\\n");', + "});", + ].join("\n"), + }); + const settings = yield* decodeCodexSettings({ + enabled: true, + binaryPath, + customModels: ["custom-model"], + }); + for (const failedMethod of ["skills/list", "model/list"] as const) { + const snapshot = yield* checkCodexProviderStatus(settings, undefined, { + ...process.env, + T3_TEST_FAILED_INVENTORY: failedMethod, + }); + assert.strictEqual(snapshot.status, "warning"); + assert.strictEqual(snapshot.auth.status, "authenticated"); + assert.deepStrictEqual( + snapshot.models.map((model) => model.slug), + ["custom-model"], + ); + assert.strictEqual( + snapshot.inventory?.models, + failedMethod === "model/list" ? "stale" : "authoritative", + ); + assert.strictEqual( + snapshot.inventory?.skills, + failedMethod === "skills/list" ? "stale" : "authoritative", + ); + } + }), + ); +}); it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 17df5d31ebb1..23d55dfb2a48 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -22,6 +22,7 @@ import type { ProviderOptionDescriptor, ServerProviderModel, ServerProviderSkill, + ProviderInventory, } from "@t3tools/contracts"; import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; @@ -31,6 +32,10 @@ import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, + AUTHORITATIVE_PROVIDER_INVENTORY, + STALE_PROVIDER_INVENTORY, + UNAVAILABLE_PROVIDER_INVENTORY, + isCommandMissingCause, type ServerProviderDraft, } from "../providerSnapshot.ts"; import { CODEX_COMPACTION } from "../Services/CodexAdapter.ts"; @@ -61,6 +66,7 @@ const CODEX_PRESENTATION = { } as const; export interface CodexAppServerProviderSnapshot { + readonly inventory: Pick; readonly account: CodexSchema.V2GetAccountResponse; readonly rateLimits?: CodexRateLimitsProbe; readonly version: string | undefined; @@ -416,6 +422,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun const accountResponse = yield* client.request("account/read", {}); if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { return { + inventory: { models: "unavailable", skills: "unavailable" }, account: accountResponse, version, models: appendCustomCodexModels([], input.customModels ?? []), @@ -423,12 +430,16 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; } - const [skillsResponse, models, rateLimits] = yield* Effect.all( + const [skillsResult, modelsResult, rateLimits] = yield* Effect.all( [ - client.request("skills/list", { - cwds: [input.cwd], - }), - requestAllCodexModels(client), + client.request("skills/list", { cwds: [input.cwd] }).pipe( + Effect.tapError((cause) => Effect.logDebug("Codex skill discovery failed.", { cause })), + Effect.result, + ), + requestAllCodexModels(client).pipe( + Effect.tapError((cause) => Effect.logDebug("Codex model discovery failed.", { cause })), + Effect.result, + ), // Usage is an enrichment: a failure or a slow answer degrades to "no // usage this probe" rather than costing the account and models. client.request("account/rateLimits/read", undefined).pipe( @@ -453,13 +464,22 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun ); return { + inventory: { + models: Result.isSuccess(modelsResult) ? "authoritative" : "stale", + skills: Result.isSuccess(skillsResult) ? "authoritative" : "stale", + }, account: accountResponse, rateLimits, version, models: applyPreferredCodexDefaultModel( - appendCustomCodexModels(models, input.customModels ?? []), + appendCustomCodexModels( + Result.isSuccess(modelsResult) ? modelsResult.success : [], + input.customModels ?? [], + ), ), - skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), + skills: Result.isSuccess(skillsResult) + ? parseCodexSkillsListResponse(skillsResult.success, input.cwd) + : [], } satisfies CodexAppServerProviderSnapshot; }); @@ -493,6 +513,7 @@ const makePendingCodexProvider = ( models, skills: [], probe: { + inventory: UNAVAILABLE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -509,6 +530,7 @@ const makePendingCodexProvider = ( models, skills: [], probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -579,6 +601,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu models: emptyModels, skills: [], probe: { + inventory: UNAVAILABLE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -603,7 +626,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu if (Result.isFailure(probeResult)) { const error = probeResult.failure; - const installed = !isCodexAppServerSpawnError(error); + const installed = !(isCodexAppServerSpawnError(error) && isCommandMissingCause(error.cause)); return buildServerProvider({ presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, @@ -611,6 +634,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu models: emptyModels, skills: [], probe: { + inventory: installed ? STALE_PROVIDER_INVENTORY : UNAVAILABLE_PROVIDER_INVENTORY, installed, version: null, status: "error", @@ -630,6 +654,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu models: emptyModels, skills: [], probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: true, version: null, status: "error", @@ -641,6 +666,8 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const snapshot = probeResult.success.value; const accountStatus = accountProbeStatus(snapshot.account); + const hasStaleInventory = + snapshot.inventory.models === "stale" || snapshot.inventory.skills === "stale"; const usageLimits = snapshot.account.account?.type === "apiKey" ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) @@ -671,11 +698,17 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }, ], probe: { + inventory: { ...AUTHORITATIVE_PROVIDER_INVENTORY, ...snapshot.inventory }, installed: true, version: snapshot.version ?? null, - status: accountStatus.status, + status: + accountStatus.status === "ready" && hasStaleInventory ? "warning" : accountStatus.status, auth: accountStatus.auth, - ...(accountStatus.message ? { message: accountStatus.message } : {}), + ...(accountStatus.message + ? { message: accountStatus.message } + : hasStaleInventory + ? { message: "Codex model or skill discovery did not complete." } + : {}), usageLimits, }, }); diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index adda9f44d465..a647ea546374 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -476,6 +476,19 @@ describe("Cursor skills", () => { }); describe("buildCursorProviderSnapshot", () => { + it("treats a successful empty model response as a complete inventory", () => { + const snapshot = buildCursorProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + cursorSettings: baseCursorSettings, + parsed: { version: "2026.04.09-f2b0fcd", status: "ready", auth: { status: "authenticated" } }, + discoveredModels: [], + discoveryWarning: "Cursor ACP model discovery returned no built-in models.", + }); + expect(snapshot.status).toBe("warning"); + expect(snapshot.inventory?.models).toBe("authoritative"); + expect(snapshot.models).toEqual([]); + }); + it("downgrades ready status to warning when ACP model discovery times out", () => { expect( buildCursorProviderSnapshot({ @@ -491,6 +504,7 @@ describe("buildCursorProviderSnapshot", () => { ).toMatchObject({ status: "warning", message: "Cursor ACP model discovery timed out after 15000ms.", + inventory: { models: "stale" }, models: [], }); }); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index e5f40882e729..266eefdac3df 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -36,6 +36,9 @@ import { buildBooleanOptionDescriptor, buildSelectOptionDescriptor, buildServerProvider, + AUTHORITATIVE_PROVIDER_INVENTORY, + STALE_PROVIDER_INVENTORY, + UNAVAILABLE_PROVIDER_INVENTORY, collectStreamAsString, isCommandMissingCause, providerModelsFromSettings, @@ -91,6 +94,7 @@ export function buildInitialCursorProviderSnapshot( checkedAt, models, probe: { + inventory: UNAVAILABLE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -106,6 +110,7 @@ export function buildInitialCursorProviderSnapshot( checkedAt, models, probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: true, version: null, status: "warning", @@ -661,6 +666,11 @@ export function buildCursorProviderSnapshot(input: { ), compaction: CURSOR_COMPACTION, probe: { + inventory: { + ...AUTHORITATIVE_PROVIDER_INVENTORY, + models: input.discoveredModels === undefined ? "stale" : "authoritative", + skills: "stale", + }, installed: true, version: input.parsed.version, status: @@ -1024,6 +1034,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( checkedAt, models: fallbackModels, probe: { + inventory: UNAVAILABLE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -1050,6 +1061,9 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( checkedAt, models: fallbackModels, probe: { + inventory: isCommandMissingCause(error) + ? UNAVAILABLE_PROVIDER_INVENTORY + : STALE_PROVIDER_INVENTORY, installed: !isCommandMissingCause(error), version: null, status: "error", @@ -1068,6 +1082,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( checkedAt, models: fallbackModels, probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: true, version: null, status: "error", @@ -1091,6 +1106,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( checkedAt, models: fallbackModels, probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: true, version: parsed.version, status: "error", @@ -1119,6 +1135,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( } else if (Option.isNone(discoveryExit.value)) { discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; } else if (discoveryExit.value.value.length === 0) { + discoveredModels = discoveryExit.value; discoveryWarning = "Cursor ACP model discovery returned no built-in models."; } else { discoveredModels = discoveryExit.value; @@ -1128,10 +1145,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( checkedAt, cursorSettings, parsed, - discoveredModels: Option.getOrElse( - Option.filter(discoveredModels, (models) => models.length > 0), - () => [] as const, - ), + ...(Option.isSome(discoveredModels) ? { discoveredModels: discoveredModels.value } : {}), ...(discoveryWarning ? { discoveryWarning } : {}), }); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index b4c131f71324..f018f1d94bf4 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -22,6 +22,9 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, + AUTHORITATIVE_PROVIDER_INVENTORY, + STALE_PROVIDER_INVENTORY, + UNAVAILABLE_PROVIDER_INVENTORY, isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, @@ -79,6 +82,7 @@ export function buildInitialGrokProviderSnapshot( checkedAt, models, probe: { + inventory: UNAVAILABLE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -94,6 +98,7 @@ export function buildInitialGrokProviderSnapshot( checkedAt, models, probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: true, version: null, status: "warning", @@ -324,7 +329,8 @@ const discoverGrokModelsViaAcpInitialize = ( clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, }); const initialized = yield* acp.initialize(); - return buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)); + const modelState = sessionModelStateFromInitialize(initialized); + return modelState === undefined ? undefined : buildGrokModelsFromSessionModelState(modelState); }).pipe(Effect.scoped); export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( @@ -346,6 +352,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func checkedAt, models: fallbackModels, probe: { + inventory: UNAVAILABLE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -371,6 +378,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func checkedAt, models: fallbackModels, probe: { + inventory: isCommandMissingCause(error) + ? UNAVAILABLE_PROVIDER_INVENTORY + : STALE_PROVIDER_INVENTORY, installed: !isCommandMissingCause(error), version: null, status: "error", @@ -389,6 +399,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func checkedAt, models: fallbackModels, probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: true, version: null, status: "error", @@ -412,6 +423,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func checkedAt, models: fallbackModels, probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: true, version, status: "error", @@ -457,14 +469,14 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func const skills = yield* discoverGrokSkills(grokSettings, environment, cwd).pipe( Effect.tapError((cause) => Effect.logDebug("Grok skill discovery failed.", { cause })), - Effect.orElseSucceed(() => []), + Effect.orElseSucceed(() => undefined), ); const acpExit = yield* discoverGrokModelsViaAcpInitialize(grokSettings, environment).pipe( Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS), Effect.exit, ); - const acpModels = Exit.isSuccess(acpExit) ? Option.getOrElse(acpExit.value, () => []) : []; + const acpModels = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined; const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value); if (acpFailed) { yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", { @@ -472,11 +484,17 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models; + const discoveredModels = acpModels ?? cliModels.models; const models = - discoveredModels.length > 0 + acpModels !== undefined || discoveredModels.length > 0 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) : fallbackModels; + const inventory = { + ...AUTHORITATIVE_PROVIDER_INVENTORY, + // The CLI fallback has model names but no option metadata. + models: acpModels === undefined ? "stale" : "authoritative", + skills: skills === undefined ? "stale" : "authoritative", + } as const; if (auth.status === "unauthenticated") { return buildServerProvider({ @@ -484,8 +502,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models, - skills, + skills: skills ?? [], probe: { + inventory: { ...inventory, slashCommands: "unavailable" }, installed: true, version, status: "error", @@ -500,9 +519,10 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func enabled: grokSettings.enabled, checkedAt, models, - skills, + skills: skills ?? [], compaction: GROK_COMPACTION, probe: { + inventory, installed: true, version, // A failed metadata probe degrades the model picker, it does not make chats fail. diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 0c0bf0c28801..d55a2dacd79a 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -9,7 +9,7 @@ import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { beforeEach } from "vite-plus/test"; -import { OpenCodeSettings } from "@t3tools/contracts"; +import { OpenCodeSettings, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { OpenCodeRuntime, @@ -19,6 +19,7 @@ import { } from "../opencodeRuntime.ts"; import * as OpenCodeServerOwner from "../OpenCodeServerOwner.ts"; import { checkOpenCodeProviderStatus } from "./OpenCodeProvider.ts"; +import { mergeProviderSnapshot } from "./ProviderRegistry.ts"; import type { OpenCodeInventory } from "../opencodeRuntime.ts"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); @@ -416,12 +417,46 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { NodeAssert.equal(snapshot.status, "error"); NodeAssert.equal(snapshot.installed, true); NodeAssert.equal(snapshot.models.length, 0); + NodeAssert.equal(snapshot.inventory?.models, "stale"); NodeAssert.equal( snapshot.message, "Failed to load OpenCode provider inventory: opencode models failed", ); }), ); + + it.effect( + "retains skills after a failed lookup and removes them after a successful empty lookup", + () => + Effect.gen(function* () { + const inventory = { + providerList: { connected: ["openai"], all: [], default: {} }, + agents: [], + skills: [{ name: "review", location: "/skills/review/SKILL.md" }], + } satisfies OpenCodeInventory; + runtimeMock.state.inventory = inventory; + const previous = { + ...(yield* checkProvider(makeOpenCodeSettings())), + driver: ProviderDriverKind.make("opencode"), + instanceId: ProviderInstanceId.make("opencode-work"), + }; + + runtimeMock.state.inventory = { ...inventory, skills: undefined }; + const failed = mergeProviderSnapshot(previous, { + ...previous, + ...(yield* checkProvider(makeOpenCodeSettings())), + }); + NodeAssert.equal(failed.status, "ready"); + NodeAssert.deepEqual(failed.skills, previous.skills); + + runtimeMock.state.inventory = { ...inventory, skills: [] }; + const empty = mergeProviderSnapshot(failed, { + ...failed, + ...(yield* checkProvider(makeOpenCodeSettings())), + }); + NodeAssert.deepEqual(empty.skills, []); + }), + ); }); it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (it) => { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index dac0fa347b68..4fce9aaf568c 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -13,6 +13,9 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { buildServerProvider, + AUTHORITATIVE_PROVIDER_INVENTORY, + STALE_PROVIDER_INVENTORY, + UNAVAILABLE_PROVIDER_INVENTORY, nonEmptyTrimmed, parseGenericCliVersion, providerModelsFromSettings, @@ -333,6 +336,7 @@ export const makePendingOpenCodeProvider = ( checkedAt, models, probe: { + inventory: UNAVAILABLE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -351,6 +355,7 @@ export const makePendingOpenCodeProvider = ( checkedAt, models, probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -393,6 +398,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu checkedAt, models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { + inventory: failure.installed ? STALE_PROVIDER_INVENTORY : UNAVAILABLE_PROVIDER_INVENTORY, installed: failure.installed, version, status: "error", @@ -409,6 +415,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu checkedAt, models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { + inventory: UNAVAILABLE_PROVIDER_INVENTORY, installed: false, version: null, status: "warning", @@ -464,6 +471,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu checkedAt, models: providerModelsFromSettings([], customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES), probe: { + inventory: STALE_PROVIDER_INVENTORY, installed: true, version, status: "error", @@ -528,6 +536,10 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu skills, compaction: OPENCODE_COMPACTION, probe: { + inventory: { + ...AUTHORITATIVE_PROVIDER_INVENTORY, + skills: inventoryExit.value.inventory.skills === undefined ? "stale" : "authoritative", + }, installed: true, version, status: connectedCount > 0 ? "ready" : "warning", diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 988c89e1e679..dabfc8c97d33 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -54,7 +54,12 @@ import { resolveProviderStatusCachePath, writeProviderStatusCache, } from "../providerStatusCache.ts"; -import { COMPACT_SLASH_COMMAND } from "../providerSnapshot.ts"; +import { + AUTHORITATIVE_PROVIDER_INVENTORY, + COMPACT_SLASH_COMMAND, + STALE_PROVIDER_INVENTORY, + UNAVAILABLE_PROVIDER_INVENTORY, +} from "../providerSnapshot.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; @@ -292,6 +297,10 @@ function makeCodexProbeSnapshot( input: Partial = {}, ): CodexAppServerProviderSnapshot { return { + inventory: + input.account?.requiresOpenaiAuth && !input.account.account + ? UNAVAILABLE_PROVIDER_INVENTORY + : AUTHORITATIVE_PROVIDER_INVENTORY, version: "1.0.0", account: { account: { @@ -519,7 +528,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Effect.fail( new CodexErrors.CodexAppServerSpawnError({ command: "codex app-server", - cause: new Error("spawn codex ENOENT"), + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), }), ), ); @@ -589,344 +602,52 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); - it("preserves previously discovered provider models when a refresh returns none", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - slashCommands: [{ name: "review", description: "Review changes" }], - skills: [ - { - name: "typescript", - description: "TypeScript help", - path: "/skills/typescript/SKILL.md", - enabled: true, - }, - ], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [], - slashCommands: [], - skills: [], - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, - [], - ); - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, refreshedProvider).skills, - [], - ); - }); - - it("drops custom models the refreshed snapshot no longer carries", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: ProviderDriverKind.make("claudeAgent"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2.1.0", - models: [ - { - slug: "claude-sonnet-4-6", - name: "Sonnet 4.6", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-custom", - name: "removed-custom", - isCustom: true, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [previousProvider.models[0]], - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...refreshedProvider.models, - ]); - }); - - it("drops stale OpenCode models missing from a successful refresh", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-plugin/model", - name: "Removed Plugin Model", - subProvider: "Removed Plugin", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-07-17T00:01:00.000Z", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - ], - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...refreshedProvider.models, - ]); - }); - - it("retains stale OpenCode models when a refresh fails", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", - version: "1.0.0", - models: [ - { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [{ name: "review", description: "Review changes" }], - skills: [ - { - name: "typescript", - description: "TypeScript help", - path: "/skills/typescript/SKILL.md", - enabled: true, - }, - ], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - status: "error", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:01:00.000Z", - models: [], - message: "Failed to refresh OpenCode models.", - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, refreshedProvider).slashCommands, - previousProvider.slashCommands, - ); - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, refreshedProvider).skills, - previousProvider.skills, - ); - }); - - it("classifies pending, logout, uninstall, and reconnect OpenCode inventories", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("opencode"), - driver: ProviderDriverKind.make("opencode"), + describe("inventory outcomes", () => { + const customModel = { + slug: "custom-model", + name: "Custom model", + isCustom: true, + capabilities: null, + } as const; + const cachedProvider = { + instanceId: ProviderInstanceId.make("personal-inventory"), + driver: ProviderDriverKind.make("custom-driver"), status: "ready", enabled: true, installed: true, auth: { status: "authenticated" }, - checkedAt: "2026-07-17T00:00:00.000Z", + checkedAt: "2026-09-04T19:00:00.000Z", version: "1.0.0", + inventory: AUTHORITATIVE_PROVIDER_INVENTORY, models: [ { - slug: "github/gpt-5", - name: "GPT-5", - subProvider: "GitHub", - isCustom: false, - capabilities: null, - }, - { - slug: "removed-plugin/model", - name: "Removed Plugin Model", - subProvider: "Removed Plugin", + slug: "current-model", + name: "Current model", isCustom: false, - capabilities: null, - }, - ], - slashCommands: [{ name: "review", description: "Review changes" }], - skills: [ - { - name: "typescript", - description: "TypeScript help", - path: "/skills/typescript/SKILL.md", - enabled: true, + capabilities: codexModelCapabilities, }, + { slug: "retired-model", name: "Retired model", isCustom: false, capabilities: null }, + { ...customModel, slug: "removed-custom", capabilities: codexModelCapabilities }, ], - } as const satisfies ServerProvider; - const pendingProvider = { - ...previousProvider, - status: "warning", - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:01:00.000Z", - version: null, - models: [], - message: "OpenCode provider status has not been checked in this session yet.", - } satisfies ServerProvider; - const loggedOutProvider = { - ...previousProvider, - status: "warning", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:02:00.000Z", - models: [], - slashCommands: [], - skills: [], - message: "OpenCode is available, but it did not report any connected upstream providers.", - } satisfies ServerProvider; - const missingProvider = { - ...previousProvider, - status: "error", - installed: false, - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:03:00.000Z", - version: null, - models: [], - message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", - } satisfies ServerProvider; - const authoritativeProvider = { - ...previousProvider, - checkedAt: "2026-07-17T00:04:00.000Z", - models: [previousProvider.models[0]!], - } satisfies ServerProvider; - const failedProvider = { - ...authoritativeProvider, - status: "error", - auth: { status: "unknown" }, - checkedAt: "2026-07-17T00:05:00.000Z", - models: [], - message: "Failed to refresh OpenCode models.", - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, pendingProvider).models, [ - ...previousProvider.models, - ]); - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, loggedOutProvider).models, - [], - ); - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, loggedOutProvider).slashCommands, - [], - ); - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, loggedOutProvider).skills, - [], - ); - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, missingProvider).models, []); - - const afterRemoval = mergeProviderSnapshot(previousProvider, authoritativeProvider); - const afterFailure = mergeProviderSnapshot(afterRemoval, failedProvider); - - assert.deepStrictEqual(afterFailure.models, [authoritativeProvider.models[0]!]); - }); - - describe("Codex model inventories", () => { - const cachedProvider = { - instanceId: ProviderInstanceId.make("codex-personal"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-09-04T19:00:00.000Z", - version: "0.153.3", - models: [ - "vega-alpha", - "joule-alpha", - "kindle-alpha", - "ultima-alpha", - "solstice-alpha", - ].map((slug) => ({ slug, name: slug, isCustom: false, capabilities: null })), - slashCommands: [], - skills: [], + slashCommands: [{ name: "review" }], + skills: [{ name: "review", path: "/skills/review/SKILL.md", enabled: true }], } satisfies ServerProvider; - const customModel = { - slug: "custom-model", - name: "Custom model", - isCustom: true, - capabilities: null, - } as const; const refreshedProvider = { ...cachedProvider, checkedAt: "2026-09-04T19:01:00.000Z", - models: [ - { slug: "gpt-6-astra", name: "GPT 6 Astra", isCustom: false, capabilities: null }, - cachedProvider.models[0]!, - customModel, - ], + models: [cachedProvider.models[0]!, customModel], + slashCommands: [], + skills: [], } satisfies ServerProvider; const pendingProvider = { ...cachedProvider, + inventory: STALE_PROVIDER_INVENTORY, status: "warning", installed: false, auth: { status: "unknown" }, models: [customModel], + slashCommands: [], + skills: [], } satisfies ServerProvider; const failedProvider = { ...pendingProvider, @@ -935,47 +656,111 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te installed: true, } satisfies ServerProvider; - it("drops retired alpha models after discovery, including without OpenAI authentication", () => { - for (const authStatus of ["authenticated", "unknown"] as const) { - assert.deepStrictEqual( - mergeProviderSnapshot(cachedProvider, { - ...refreshedProvider, - auth: { status: authStatus }, - }).models, - refreshedProvider.models, - ); + it("replaces complete inventories without guessing from health or authentication", () => { + for (const status of ["ready", "warning", "error"] as const) { + for (const auth of ["authenticated", "unknown", "unauthenticated"] as const) { + const refreshed = { ...refreshedProvider, status, auth: { status: auth } }; + assert.deepStrictEqual(mergeProviderSnapshot(cachedProvider, refreshed), refreshed); + } } }); - it("keeps discovered models during startup and failed probes without restoring removed custom models", () => { + it("retains stale discoveries but does not restore removed custom rows", () => { for (const provider of [pendingProvider, failedProvider]) { - assert.deepStrictEqual( - mergeProviderSnapshot( - { - ...cachedProvider, - models: [...cachedProvider.models, { ...customModel, slug: "removed-custom" }], - }, - provider, - ).models, - [customModel, ...cachedProvider.models], - ); + const merged = mergeProviderSnapshot(cachedProvider, provider); + assert.deepStrictEqual(merged.models, [ + customModel, + ...cachedProvider.models.filter((model) => !model.isCustom), + ]); + assert.deepStrictEqual(merged.skills, cachedProvider.skills); + assert.deepStrictEqual(merged.slashCommands, cachedProvider.slashCommands); } }); - it("clears discovered models after sign-out, disable, uninstall, or empty discovery", () => { - const emptyProvider = { ...refreshedProvider, models: [customModel] }; - const clearedProviders = [ - { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, - { ...emptyProvider, status: "disabled", enabled: false }, - { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, - emptyProvider, - { ...emptyProvider, models: [] }, - ] satisfies ReadonlyArray; + it("uses each inventory's outcome independently", () => { + const refreshed = { + ...refreshedProvider, + inventory: { ...AUTHORITATIVE_PROVIDER_INVENTORY, skills: "stale" }, + } satisfies ServerProvider; + const merged = mergeProviderSnapshot(cachedProvider, refreshed); + assert.deepStrictEqual(merged.models, refreshed.models); + assert.deepStrictEqual(merged.slashCommands, []); + assert.deepStrictEqual(merged.skills, cachedProvider.skills); + }); + + it("does not restore an inventory after a successful empty result", () => { + const emptyProvider = { ...refreshedProvider, models: [] }; + const afterEmpty = mergeProviderSnapshot(cachedProvider, emptyProvider); + const afterFailure = mergeProviderSnapshot(afterEmpty, { ...failedProvider, models: [] }); + assert.deepStrictEqual(afterFailure.models, []); + assert.deepStrictEqual(afterFailure.skills, []); + assert.deepStrictEqual(afterFailure.slashCommands, []); + }); + + it("clears unavailable discoveries while keeping current custom settings", () => { + const unavailable = { + ...failedProvider, + inventory: UNAVAILABLE_PROVIDER_INVENTORY, + auth: { status: "unauthenticated" }, + } satisfies ServerProvider; + const cleared = mergeProviderSnapshot(cachedProvider, unavailable); + assert.deepStrictEqual(cleared.models, [customModel]); + assert.deepStrictEqual(cleared.skills, []); + assert.deepStrictEqual(cleared.slashCommands, []); + assert.deepStrictEqual(mergeProviderSnapshot(cleared, failedProvider).models, [ + customModel, + ]); + }); + + it("retains old capabilities only for stale discovered rows", () => { + const current = cachedProvider.models[0]!; + const nextModel = { ...current, capabilities: null }; + assert.strictEqual( + mergeProviderSnapshot(cachedProvider, { ...refreshedProvider, models: [nextModel] }) + .models[0]?.capabilities, + null, + ); + assert.deepStrictEqual( + mergeProviderSnapshot(cachedProvider, { ...failedProvider, models: [nextModel] }) + .models[0], + current, + ); + const nextCustom = { ...nextModel, isCustom: true }; + assert.deepStrictEqual( + mergeProviderSnapshot(cachedProvider, { ...failedProvider, models: [nextCustom] }) + .models, + [nextCustom, cachedProvider.models[1]!], + ); + assert.deepStrictEqual( + mergeProviderSnapshot( + { + ...cachedProvider, + models: [{ ...nextCustom, capabilities: codexModelCapabilities }], + }, + { ...failedProvider, models: [nextCustom] }, + ).models, + [nextCustom], + ); + }); - for (const provider of clearedProviders) { + it("keeps legacy snapshot handling independent of driver names", () => { + const { inventory: _inventory, ...legacy } = failedProvider; + const merged = mergeProviderSnapshot(cachedProvider, legacy); + assert.deepStrictEqual(merged.models, [ + customModel, + ...cachedProvider.models.filter((model) => !model.isCustom), + ]); + assert.deepStrictEqual(merged.skills, []); + }); + + it("does not reuse a different driver or instance's inventory", () => { + for (const previous of [ + { ...cachedProvider, driver: ProviderDriverKind.make("other-driver") }, + { ...cachedProvider, instanceId: ProviderInstanceId.make("other-instance") }, + ]) { assert.deepStrictEqual( - mergeProviderSnapshot(cachedProvider, provider).models, - provider.models, + mergeProviderSnapshot(previous, pendingProvider), + pendingProvider, ); } }); @@ -994,7 +779,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te driverKind: cachedProvider.driver, continuationIdentity: { driverKind: cachedProvider.driver, - continuationKey: "codex:instance:codex-personal", + continuationKey: "custom-driver:personal", }, displayName: undefined, enabled: true, @@ -1035,7 +820,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const registry = yield* ProviderRegistry.ProviderRegistry; const expectedModels = restarted ? retainedModels - : [customModel, ...cachedProvider.models]; + : [customModel, ...cachedProvider.models.filter((model) => !model.isCustom)]; assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, expectedModels); yield* registry.refreshInstance(instance.instanceId); @@ -1059,126 +844,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }).pipe( Effect.provide( ServerConfig.layerTest(process.cwd(), { - prefix: "t3-codex-retired-model-cache-", + prefix: "t3-provider-inventory-cache-", }).pipe(Layer.provideMerge(NodeServices.layer)), ), ), ); }); - describe("Antigravity model inventories", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("antigravity-personal"), - driver: ProviderDriverKind.make("antigravity"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-09-02T00:00:00.000Z", - version: "0.1.3", - models: [ - { - slug: "gemini-3.1-pro-high", - name: "Gemini 3.1 Pro High", - isCustom: false, - capabilities: null, - }, - { - slug: "gemini-3-flash", - name: "Gemini 3 Flash", - isCustom: false, - capabilities: null, - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - - it("removes unavailable models after a successful refresh", () => { - for (const status of ["ready", "warning"] as const) { - const refreshedProvider = { - ...previousProvider, - status, - checkedAt: "2026-09-02T00:01:00.000Z", - models: [previousProvider.models[1]], - } satisfies ServerProvider; - const afterRefresh = mergeProviderSnapshot(previousProvider, refreshedProvider); - - assert.deepStrictEqual(afterRefresh.models, refreshedProvider.models); - - const afterFailure = mergeProviderSnapshot(afterRefresh, { - ...refreshedProvider, - status: "error", - auth: { status: "unknown" }, - models: [], - }); - assert.deepStrictEqual(afterFailure.models, refreshedProvider.models); - } - }); - - it("keeps cached models during health checks and temporary failures", () => { - for (const installed of [false, true]) { - const pendingProvider = { - ...previousProvider, - status: "warning", - installed, - auth: { status: "unknown" }, - checkedAt: "2026-09-02T00:01:00.000Z", - version: installed ? previousProvider.version : null, - models: [], - } satisfies ServerProvider; - - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, pendingProvider).models, - previousProvider.models, - ); - } - - for (const authStatus of ["unknown", "authenticated"] as const) { - const failedProvider = { - ...previousProvider, - status: "error", - auth: { status: authStatus }, - checkedAt: "2026-09-02T00:02:00.000Z", - models: [], - } satisfies ServerProvider; - - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, failedProvider).models, - previousProvider.models, - ); - } - }); - - it("clears models after sign-out, disable, uninstall, or an empty successful refresh", () => { - const emptyProvider = { - ...previousProvider, - checkedAt: "2026-09-02T00:01:00.000Z", - models: [], - } satisfies ServerProvider; - const clearedProviders = [ - { ...emptyProvider, status: "warning", auth: { status: "unauthenticated" } }, - { ...emptyProvider, status: "error", auth: { status: "unauthenticated" } }, - { ...emptyProvider, status: "disabled", enabled: false }, - { ...emptyProvider, status: "error", enabled: false }, - { ...emptyProvider, status: "error", installed: false, auth: { status: "unknown" } }, - emptyProvider, - ] satisfies ReadonlyArray; - - for (const provider of clearedProviders) { - const afterRemoval = mergeProviderSnapshot(previousProvider, provider); - assert.deepStrictEqual(afterRemoval.models, []); - - const afterFailure = mergeProviderSnapshot(afterRemoval, { - ...emptyProvider, - status: "error", - auth: { status: "unknown" }, - }); - assert.deepStrictEqual(afterFailure.models, []); - } - }); - }); - describe("Antigravity saved account", () => { const signedIn = { instanceId: ProviderInstanceId.make("antigravity-personal"), @@ -1286,54 +958,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); }); - it("fills missing capabilities from the previous provider snapshot", () => { - const previousProvider = { - instanceId: ProviderInstanceId.make("cursor"), - driver: ProviderDriverKind.make("cursor"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-04-14T00:00:00.000Z", - version: "2026.04.09-f2b0fcd", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("reasoning", "Reasoning", [ - { id: "high", label: "High", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast Mode"), - booleanDescriptor("thinking", "Thinking"), - ], - }), - }, - ], - slashCommands: [], - skills: [], - } as const satisfies ServerProvider; - const refreshedProvider = { - ...previousProvider, - checkedAt: "2026-04-14T00:01:00.000Z", - models: [ - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [], - }), - }, - ], - } satisfies ServerProvider; - - assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ - ...previousProvider.models, - ]); - }); it.effect("does not run provider probes during layer construction", () => Effect.gen(function* () { @@ -1429,15 +1053,27 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te models: [], slashCommands: [{ name: "global" }], skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + workspaceSnapshots: [ + { + cwd: "/workspace", + checkedAt: "2026-06-10T00:00:00.000Z", + slashCommands: [{ name: "project" }], + skills: [], + inventory: { slashCommands: "authoritative", skills: "stale" }, + }, + ], } as const satisfies ServerProvider; const scopedProvider = { ...machineProvider, + inventory: AUTHORITATIVE_PROVIDER_INVENTORY, + status: "error", checkedAt: "2026-06-10T00:01:00.000Z", slashCommands: [{ name: "project" }], skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], } as const satisfies ServerProvider; const pendingScopedProvider = { ...scopedProvider, + inventory: STALE_PROVIDER_INVENTORY, status: "error", installed: false, slashCommands: [], @@ -1484,8 +1120,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te return scopedProvider; }), ); + const { workspaceSnapshots: _oldWorkspaces, ...machineMetadata } = machineProvider; const rebuiltProvider = { - ...machineProvider, + ...machineMetadata, checkedAt: "2026-06-10T00:02:00.000Z", status: "warning", installed: false, @@ -1528,7 +1165,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.strictEqual((yield* registry.getProviders)[0]?.workspaceSnapshots, undefined); + assert.deepStrictEqual( + (yield* registry.getProviders)[0]?.workspaceSnapshots, + machineProvider.workspaceSnapshots, + ); yield* Ref.set(returnPendingSnapshot, false); const workspaceUpdate = yield* registry.streamChanges.pipe( Stream.runHead, @@ -1558,17 +1198,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); assert.strictEqual(yield* Ref.get(snapshotCalls), 2); + const rebuiltUpdate = yield* awaitPersistedProvider( + registry, + rebuiltProvider.checkedAt, + ); yield* Ref.set(instancesRef, [rebuiltInstance]); yield* PubSub.publish(registryChanges, undefined); - let rebuilt = yield* registry.getProviders; - for ( - let attempt = 0; - attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; - attempt += 1 - ) { - yield* Effect.yieldNow; - rebuilt = yield* registry.getProviders; - } + yield* Fiber.join(rebuiltUpdate); + const rebuilt = yield* registry.getProviders; assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); }).pipe(Effect.provide(runtimeServices)); @@ -1597,6 +1234,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te skills: [], } as const satisfies ServerProvider; const failedOpenCodeProvider = { + inventory: STALE_PROVIDER_INVENTORY, instanceId: openCodeInstanceId, driver: openCodeDriver, status: "error", @@ -1612,6 +1250,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te } as const satisfies ServerProvider; const recoveredOpenCodeProvider = { ...failedOpenCodeProvider, + inventory: AUTHORITATIVE_PROVIDER_INVENTORY, status: "ready", auth: { status: "authenticated" }, checkedAt: "2026-06-10T00:01:00.000Z", @@ -1908,11 +1547,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te } as const satisfies ServerProvider; const authoritativeProvider = { ...initialProvider, + inventory: AUTHORITATIVE_PROVIDER_INVENTORY, checkedAt: "2026-07-17T00:01:00.000Z", models: [initialProvider.models[0]!], } satisfies ServerProvider; const failedProvider = { ...authoritativeProvider, + inventory: STALE_PROVIDER_INVENTORY, status: "error", auth: { status: "unknown" }, checkedAt: "2026-07-17T00:02:00.000Z", diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7..fcf340e9be71 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -24,6 +24,7 @@ */ import { defaultInstanceIdForDriver, + hasProviderWorkspaceSkills, ProviderDriverKind, type ProviderInstanceId, type ServerProvider, @@ -54,6 +55,7 @@ import { import type { ProviderInstance } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; +import { mergeProviderModels, mergeProviderWorkspaceInventories } from "../providerSnapshot.ts"; const loadProviders = ( providerSources: ReadonlyArray, @@ -75,9 +77,6 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) packageName: null, }); -const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => - (model.capabilities?.optionDescriptors?.length ?? 0) > 0; - const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; export function upsertProviderWorkspaceSnapshot( @@ -85,11 +84,22 @@ export function upsertProviderWorkspaceSnapshot( cwd: string, scopedSnapshot: ServerProvider, ): ServerProvider { + const previous = provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); const workspaceSnapshot = { cwd, checkedAt: scopedSnapshot.checkedAt, - slashCommands: scopedSnapshot.slashCommands, - skills: scopedSnapshot.skills, + ...mergeProviderWorkspaceInventories( + previous ?? { slashCommands: [], skills: [] }, + scopedSnapshot, + ), + ...(scopedSnapshot.inventory + ? { + inventory: { + slashCommands: scopedSnapshot.inventory.slashCommands, + skills: scopedSnapshot.inventory.skills, + }, + } + : {}), } satisfies NonNullable[number]; return { ...provider, @@ -100,68 +110,6 @@ export function upsertProviderWorkspaceSnapshot( }; } -const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { - const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); - const isCodex = provider.driver === ProviderDriverKind.make("codex"); - if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { - return true; - } - - if ( - (isAntigravity || isCodex) && - (!provider.enabled || provider.auth.status === "unauthenticated") - ) { - return false; - } - - // Successful discovery replaces these inventories so cached retired models disappear. - // Antigravity's local health check does not authenticate or discover models. - const isPendingAntigravityAuthentication = - isAntigravity && provider.status === "warning" && provider.auth.status === "unknown"; - const isPendingInitialProbe = - provider.enabled && !provider.installed && provider.status === "warning"; - const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; - return ( - isPendingAntigravityAuthentication || isPendingInitialProbe || didInstalledProviderProbeFail - ); -}; - -const shouldRetainMissingOpenCodeMetadata = (provider: ServerProvider): boolean => - provider.driver === ProviderDriverKind.make("opencode") && - shouldRetainMissingProviderModels(provider); - -const mergeProviderModels = ( - provider: ServerProvider, - previousModels: ReadonlyArray, - nextModels: ReadonlyArray, -): ReadonlyArray => { - const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); - // Custom rows are derived from settings and every snapshot carries the full - // current list, so a custom model missing from `nextModels` was removed by - // the user and must not be resurrected from the previous snapshot. - const retainablePreviousModels = previousModels.filter((model) => !model.isCustom); - - if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) { - return retainablePreviousModels; - } - - const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); - const mergedModels = nextModels.map((model) => { - const previousModel = previousBySlug.get(model.slug); - if (!previousModel || hasModelCapabilities(model) || !hasModelCapabilities(previousModel)) { - return model; - } - return { - ...model, - capabilities: previousModel.capabilities, - }; - }); - const nextSlugs = new Set(nextModels.map((model) => model.slug)); - return shouldRetainMissingModels - ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))] - : mergedModels; -}; - /** * Antigravity's health check only initializes the agent, so after a server * restart it reports the account as unchecked. The saved Google login still @@ -198,31 +146,38 @@ export const mergeProviderSnapshot = ( previousProvider: ServerProvider | undefined, nextProvider: ServerProvider, ): ServerProvider => { - if (!previousProvider) { - return nextProvider; - } + if ( + !previousProvider || + previousProvider.instanceId !== nextProvider.instanceId || + previousProvider.driver !== nextProvider.driver + ) return nextProvider; const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); - // "Google account access is not checked yet" describes the probe, not the - // account; it must not outlive the state it explained. + // A passed health check no longer needs the unchecked-account message. const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; return { ...(savedAccount?.status === "ready" ? nextWithoutMessage : nextProvider), ...savedAccount, - models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), + models: mergeProviderModels( + previousProvider.models, + nextProvider.models, + nextProvider.inventory?.models ?? "stale", + ), + ...mergeProviderWorkspaceInventories(previousProvider, nextProvider), ...(nextProvider.workspaceSnapshots !== undefined - ? { workspaceSnapshots: nextProvider.workspaceSnapshots } + ? { + workspaceSnapshots: nextProvider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + ...mergeProviderWorkspaceInventories( + previousProvider.workspaceSnapshots?.find( + (previous) => previous.cwd === snapshot.cwd, + ) ?? { slashCommands: [], skills: [] }, + snapshot, + ), + })), + } : previousProvider.workspaceSnapshots !== undefined ? { workspaceSnapshots: previousProvider.workspaceSnapshots } : {}), - ...(shouldRetainMissingOpenCodeMetadata(nextProvider) - ? { - slashCommands: - nextProvider.slashCommands.length === 0 - ? previousProvider.slashCommands - : nextProvider.slashCommands, - skills: nextProvider.skills.length === 0 ? previousProvider.skills : nextProvider.skills, - } - : {}), }; }; @@ -809,11 +764,7 @@ export const ProviderRegistryLive = Layer.effect( }) { const providers = yield* Ref.get(providersRef); const provider = providers.find((candidate) => candidate.instanceId === input.instanceId); - if ( - !provider || - !provider.enabled || - provider.workspaceSnapshots?.some((s) => s.cwd === input.cwd) - ) { + if (!provider || !provider.enabled || hasProviderWorkspaceSkills(provider, input.cwd)) { return providers; } const instance = yield* instanceRegistry.getInstance(input.instanceId); @@ -828,7 +779,7 @@ export const ProviderRegistryLive = Layer.effect( if (!claimed) return yield* Ref.get(providersRef); return yield* instance.snapshotForCwd(input.cwd).pipe( Effect.flatMap((scopedSnapshot) => - scopedSnapshot.status === "error" + scopedSnapshot.inventory?.skills === "stale" ? Ref.get(providersRef) : instanceRegistry.getInstance(input.instanceId).pipe( Effect.flatMap((currentInstance) => { @@ -836,7 +787,7 @@ export const ProviderRegistryLive = Layer.effect( return Ref.modify(providersRef, (currentProviders) => { const nextProviders = currentProviders.map((candidate) => candidate.instanceId === input.instanceId && - !candidate.workspaceSnapshots?.some((s) => s.cwd === input.cwd) + !hasProviderWorkspaceSkills(candidate, input.cwd) ? upsertProviderWorkspaceSnapshot(candidate, input.cwd, scopedSnapshot) : candidate, ); diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 35a7791c62f0..1402b57afcb9 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -280,8 +280,9 @@ describe("parseSkillsCliOutput", () => { ]); }); - it("degrades malformed output to an empty skill list", () => { - NodeAssert.deepEqual(parseSkillsCliOutput("not json"), []); + it("distinguishes malformed output from an empty skill list", () => { + NodeAssert.equal(parseSkillsCliOutput("not json"), undefined); + NodeAssert.deepEqual(parseSkillsCliOutput("[]"), []); }); }); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 39ffec7436d4..9475dbb20755 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -110,7 +110,7 @@ it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); NodeAssert.deepEqual(inventory.agents, []); - NodeAssert.deepEqual(inventory.skills, []); + NodeAssert.equal(inventory.skills, undefined); }), ); @@ -207,7 +207,7 @@ it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { }); NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); - NodeAssert.equal(inventory.skills.length, 0); + NodeAssert.equal(inventory.skills, undefined); }), ); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 19725d9472ca..a342ea51643d 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -186,7 +186,7 @@ export interface OpenCodeCommandResult { export interface OpenCodeInventory { readonly providerList: ProviderListResponse; readonly agents: ReadonlyArray; - readonly skills: ReadonlyArray; + readonly skills: ReadonlyArray | undefined; } export interface ParsedOpenCodeModelSlug { @@ -395,9 +395,9 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { } /** @internal */ -export function parseSkillsCliOutput(stdout: string): ReadonlyArray { +export function parseSkillsCliOutput(stdout: string): ReadonlyArray | undefined { const result = decodeOpenCodeSkillsCliOutputExit(stdout); - return Exit.isSuccess(result) ? result.value : []; + return Exit.isSuccess(result) ? result.value : undefined; } export function parseOpenCodeModelSlug( @@ -904,16 +904,25 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe( - Effect.map((result) => - (result.data ?? []).map((skill) => ({ - name: skill.name, - ...(skill.description === undefined ? {} : { description: skill.description }), - location: skill.location, - })), + Effect.flatMap((result) => + result.data === undefined + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "app.skills", + detail: "OpenCode did not return a skill inventory.", + }), + ) + : Effect.succeed( + result.data.map((skill) => ({ + name: skill.name, + ...(skill.description === undefined ? {} : { description: skill.description }), + location: skill.location, + })), + ), ), ); const loadSkills = (client: OpencodeClient) => - loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + loadOpenCodeSkills(client).pipe(Effect.orElseSucceed(() => undefined)); const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { @@ -1007,7 +1016,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { agents = parseAgentListCliOutput(agentsResult.value.stdout); } - let skills: ReadonlyArray = []; + let skills: ReadonlyArray | undefined; if (skillsResult._tag === "Success" && skillsResult.value.code === 0) { skills = parseSkillsCliOutput(skillsResult.value.stdout); } @@ -1027,16 +1036,20 @@ const makeOpenCodeRuntime = Effect.gen(function* () { maxOutputBytes: OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES, ...(input.environment !== undefined ? { environment: input.environment } : {}), }).pipe( - Effect.flatMap((result) => - result.code === 0 - ? Effect.succeed(parseSkillsCliOutput(result.stdout)) + Effect.flatMap((result) => { + const skills = result.code === 0 ? parseSkillsCliOutput(result.stdout) : undefined; + return skills !== undefined + ? Effect.succeed(skills) : Effect.fail( new OpenCodeRuntimeError({ operation: "loadSkillsFromCli", - detail: `OpenCode skills command exited with code ${result.code}.`, + detail: + result.code === 0 + ? "OpenCode did not return a valid skill inventory." + : `OpenCode skills command exited with code ${result.code}.`, }), - ), - ), + ); + }), ); return { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 3e9012d61e61..b256c63e7f38 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -9,6 +9,9 @@ import type { ServerProviderModel, ServerProviderState, ServerProviderUsageLimits, + ProviderInventory, + ProviderInventoryState, + ProviderWorkspaceInventory, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as PlatformError from "effect/PlatformError"; @@ -25,6 +28,24 @@ export const DEFAULT_TIMEOUT_MS = 4_000; // Auth status checks involve disk/network lookups and can be slow on first run (especially Windows) export const AUTH_PROBE_TIMEOUT_MS = 10_000; +export const AUTHORITATIVE_PROVIDER_INVENTORY = { + models: "authoritative", + slashCommands: "authoritative", + skills: "authoritative", +} as const satisfies ProviderInventory; + +export const STALE_PROVIDER_INVENTORY = { + models: "stale", + slashCommands: "stale", + skills: "stale", +} as const satisfies ProviderInventory; + +export const UNAVAILABLE_PROVIDER_INVENTORY = { + models: "unavailable", + slashCommands: "unavailable", + skills: "unavailable", +} as const satisfies ProviderInventory; + export const COMPACT_SLASH_COMMAND = { name: "compact", description: "Summarize the conversation and reduce context usage", @@ -66,6 +87,7 @@ export class ProviderCommandNotFoundError extends Schema.TaggedErrorClass; @@ -274,6 +296,7 @@ export function buildServerProvider(input: { checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), models: input.models, + inventory: input.probe.inventory, slashCommands: withCompactionSlashCommand(input.slashCommands ?? [], input.compaction), skills: [...(input.skills ?? [])], ...(input.probe.usageLimits ? { usageLimits: input.probe.usageLimits } : {}), @@ -281,6 +304,59 @@ export function buildServerProvider(input: { }; } +function retainMissingItems( + previous: ReadonlyArray, + next: ReadonlyArray, + key: (item: T) => string, +): ReadonlyArray { + if (previous.length === 0) return next; + if (next.length === 0) return previous; + const nextKeys = new Set(next.map(key)); + return [...next, ...previous.filter((item) => !nextKeys.has(key(item)))]; +} + +/** Settings own custom rows, even when discovery must retain an older inventory. */ +export function mergeProviderModels( + previous: ReadonlyArray, + next: ReadonlyArray, + state: ProviderInventoryState, +): ReadonlyArray { + if (state !== "stale") return next; + + const previousBuiltIns = previous.filter((model) => !model.isCustom); + const previousBySlug = new Map(previousBuiltIns.map((model) => [model.slug, model])); + const models = next.map((model) => { + const previousModel = previousBySlug.get(model.slug); + if ( + model.isCustom || + !previousModel || + (model.capabilities?.optionDescriptors?.length ?? 0) > 0 || + (previousModel.capabilities?.optionDescriptors?.length ?? 0) === 0 + ) + return model; + return { ...model, capabilities: previousModel.capabilities }; + }); + return retainMissingItems(previousBuiltIns, models, (model) => model.slug); +} + +export function mergeProviderWorkspaceInventories( + previous: Pick, + next: Pick & { + readonly inventory?: ProviderWorkspaceInventory; + }, +) { + return { + slashCommands: + next.inventory?.slashCommands === "stale" + ? retainMissingItems(previous.slashCommands, next.slashCommands, (command) => command.name) + : next.slashCommands, + skills: + next.inventory?.skills === "stale" + ? retainMissingItems(previous.skills, next.skills, (skill) => skill.path) + : next.skills, + }; +} + export const collectStreamAsString = ( stream: Stream.Stream, options?: { readonly maxBytes?: number | undefined }, diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index cd7bfff273f8..a987b69ae2d8 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -11,6 +11,8 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Logger from "effect/Logger"; +import { AUTHORITATIVE_PROVIDER_INVENTORY } from "./providerSnapshot.ts"; + import { hydrateCachedProvider, isCachedProviderCorrelated, @@ -211,6 +213,21 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { ); }); + it("does not replace a complete current inventory with a stale cache", () => { + const cached = makeProvider(CODEX_DRIVER, { + models: [ + { slug: "retired-model", name: "Retired model", isCustom: false, capabilities: null }, + ], + slashCommands: [{ name: "removed-command" }], + skills: [{ name: "removed-skill", path: "/skills/removed/SKILL.md", enabled: true }], + }); + const current = makeProvider(CODEX_DRIVER, { inventory: AUTHORITATIVE_PROVIDER_INVENTORY }); + const hydrated = hydrateCachedProvider({ cachedProvider: cached, fallbackProvider: current }); + assert.deepStrictEqual(hydrated.models, []); + assert.deepStrictEqual(hydrated.slashCommands, []); + assert.deepStrictEqual(hydrated.skills, []); + }); + it("ignores stale cached enabled state when the provider is now disabled", () => { const cachedCodex = makeProvider(CODEX_DRIVER, { checkedAt: "2026-04-10T12:00:00.000Z", diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index a4b260f43bf6..c00fdce7f7a3 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -10,25 +10,12 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { writeFileStringAtomically } from "../atomicWrite.ts"; +import { mergeProviderModels, mergeProviderWorkspaceInventories } from "./providerSnapshot.ts"; const decodeProviderStatusCache = Schema.decodeUnknownEffect( Schema.fromJsonString(ServerProviderSchema), ); -const mergeProviderModels = ( - fallbackModels: ReadonlyArray, - cachedModels: ReadonlyArray, -): ReadonlyArray => { - const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug)); - // The fallback snapshot is built from current settings and already carries - // every custom model, so cached custom rows that are not in it were removed - // while the cache was stale and must not come back. - return [ - ...fallbackModels, - ...cachedModels.filter((model) => !model.isCustom && !fallbackSlugs.has(model.slug)), - ]; -}; - /** * Built-in drivers in presentation order. Codex and Claude lead, the opt-in * providers follow, and unknown or fork drivers sort after every built-in. @@ -83,14 +70,23 @@ export const hydrateCachedProvider = (input: { const { message: _fallbackMessage, ...fallbackWithoutMessage } = input.fallbackProvider; const hydratedProvider: ServerProvider = { ...fallbackWithoutMessage, - models: mergeProviderModels(input.fallbackProvider.models, input.cachedProvider.models), + models: mergeProviderModels( + input.cachedProvider.models, + input.fallbackProvider.models, + input.fallbackProvider.inventory?.models ?? "stale", + ), installed: input.cachedProvider.installed, version: input.cachedProvider.version, status: input.cachedProvider.status, auth: input.cachedProvider.auth, checkedAt: input.cachedProvider.checkedAt, - slashCommands: input.cachedProvider.slashCommands, - skills: input.cachedProvider.skills, + ...mergeProviderWorkspaceInventories(input.cachedProvider, { + ...input.fallbackProvider, + inventory: input.fallbackProvider.inventory ?? { + slashCommands: "stale", + skills: "stale", + }, + }), }; return input.cachedProvider.message diff --git a/apps/server/src/provider/unavailableProviderSnapshot.ts b/apps/server/src/provider/unavailableProviderSnapshot.ts index de0c799a8cd3..9a983f2dcfe3 100644 --- a/apps/server/src/provider/unavailableProviderSnapshot.ts +++ b/apps/server/src/provider/unavailableProviderSnapshot.ts @@ -18,7 +18,7 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import { buildServerProvider } from "./providerSnapshot.ts"; +import { buildServerProvider, UNAVAILABLE_PROVIDER_INVENTORY } from "./providerSnapshot.ts"; export interface UnavailableProviderSnapshotInput { readonly driverKind: ProviderDriverKind | string; @@ -56,6 +56,7 @@ export function buildUnavailableProviderSnapshot( models: [], skills: [], probe: { + inventory: UNAVAILABLE_PROVIDER_INVENTORY, installed: false, version: null, status: "error", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0a54a2a55abb..767a6f869bd7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -15,6 +15,7 @@ import type { ThreadId, } from "@t3tools/contracts"; import { + hasProviderWorkspaceSkills, ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -1660,10 +1661,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const workspaceRefreshRetryRef = useRef<{ key: string; notBefore: number } | null>(null); const hadWorkspaceSnapshotRef = useRef(false); useEffect(() => { - const hasWorkspaceSnapshot = Boolean( - gitCwd && - selectedProviderStatus?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd), - ); + const hasWorkspaceSnapshot = hasProviderWorkspaceSkills(selectedProviderStatus, gitCwd); if (hadWorkspaceSnapshotRef.current && !hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = null; workspaceRefreshRetryRef.current = null; @@ -1673,9 +1671,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useEffect(() => { if (!gitCwd || !selectedProviderEntry) return; const key = `${environmentId}:${selectedProviderEntry.instanceId}:${gitCwd}`; - const hasWorkspaceSnapshot = selectedProviderStatus?.workspaceSnapshots?.some( - (snapshot) => snapshot.cwd === gitCwd, - ); + const hasWorkspaceSnapshot = hasProviderWorkspaceSkills(selectedProviderStatus, gitCwd); if (workspaceRefreshKeyRef.current === key) return; if (hasWorkspaceSnapshot) { workspaceRefreshKeyRef.current = key; @@ -1699,9 +1695,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }).then((result) => { const hasWorkspaceSnapshot = result._tag === "Success" && - result.value.providers - .find((provider) => provider.instanceId === selectedProviderEntry.instanceId) - ?.workspaceSnapshots?.some((snapshot) => snapshot.cwd === gitCwd); + hasProviderWorkspaceSkills( + result.value.providers.find( + (provider) => provider.instanceId === selectedProviderEntry.instanceId, + ), + gitCwd, + ); if (!hasWorkspaceSnapshot && workspaceRefreshKeyRef.current === key) { retryLater(); } diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 62d0319db1bc..5e581dba2916 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -1,4 +1,9 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + hasProviderWorkspaceSkills, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -238,7 +243,54 @@ describe("resolveProviderSkillSourceKind", () => { }); describe("workspace provider snapshots", () => { + it("does not mistake native commands for a completed skill scan", () => { + const cwd = "/workspace/project-a"; + const pending = { + ...provider, + workspaceSnapshots: [ + { + ...provider.workspaceSnapshots[0]!, + inventory: { slashCommands: "authoritative", skills: "stale" }, + skills: [], + }, + ], + } satisfies ServerProvider; + expect(hasProviderWorkspaceSkills(pending, cwd)).toBe(false); + expect(resolveProviderSkillsForCwd(pending, cwd)).toEqual(provider.skills); + expect(resolveProviderSlashCommandsForCwd(pending, cwd)).toEqual([{ name: "project" }]); + + const completed = { + ...pending, + workspaceSnapshots: [ + { + ...pending.workspaceSnapshots[0]!, + inventory: { slashCommands: "authoritative", skills: "authoritative" }, + }, + ], + } satisfies ServerProvider; + expect(hasProviderWorkspaceSkills(completed, cwd)).toBe(true); + expect(resolveProviderSkillsForCwd(completed, cwd)).toEqual([]); + }); + + it("keeps current machine commands until workspace commands are known", () => { + const pending = { + ...provider, + workspaceSnapshots: [ + { + ...provider.workspaceSnapshots[0]!, + inventory: { slashCommands: "stale", skills: "authoritative" }, + slashCommands: [], + }, + ], + } satisfies ServerProvider; + expect(resolveProviderSlashCommandsForCwd(pending, "/workspace/project-a")).toEqual( + provider.slashCommands, + ); + expect(hasProviderWorkspaceSkills(pending, "/workspace/project-a")).toBe(true); + }); + it("uses the cwd snapshot after a provider session has populated it", () => { + expect(hasProviderWorkspaceSkills(provider, "/workspace/project-a")).toBe(true); expect(resolveProviderSkillsForCwd(provider, "/workspace/project-a")).toEqual([ { name: "project", path: "/workspace/project-a/SKILL.md", enabled: true }, ]); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index b80cd5803809..6ae8f5fa811b 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -115,12 +115,18 @@ export function resolveProviderSkillsForCwd( provider: ServerProvider, cwd: string | null | undefined, ): ServerProvider["skills"] { - return resolveProviderWorkspaceSnapshot(provider, cwd)?.skills ?? provider.skills; + const workspace = resolveProviderWorkspaceSnapshot(provider, cwd); + return workspace?.inventory?.skills === "stale" && workspace.skills.length === 0 + ? provider.skills + : (workspace?.skills ?? provider.skills); } export function resolveProviderSlashCommandsForCwd( provider: ServerProvider, cwd: string | null | undefined, ): ServerProvider["slashCommands"] { - return resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommands ?? provider.slashCommands; + const workspace = resolveProviderWorkspaceSnapshot(provider, cwd); + return workspace?.inventory?.slashCommands === "stale" + ? provider.slashCommands + : (workspace?.slashCommands ?? provider.slashCommands); } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3ea7bed8f1c4..9e5b6edf7408 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -115,11 +115,32 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +/** + * Authoritative results replace the inventory, including an empty result. + * Stale results retain previously discovered entries. Unavailable results do + * not reuse them, for example after sign-out or disabling a provider. + */ +export const ProviderInventoryState = Schema.Literals(["authoritative", "stale", "unavailable"]); +export type ProviderInventoryState = typeof ProviderInventoryState.Type; + +export const ProviderWorkspaceInventory = Schema.Struct({ + slashCommands: ProviderInventoryState, + skills: ProviderInventoryState, +}); +export type ProviderWorkspaceInventory = typeof ProviderWorkspaceInventory.Type; + +export const ProviderInventory = Schema.Struct({ + models: ProviderInventoryState, + ...ProviderWorkspaceInventory.fields, +}); +export type ProviderInventory = typeof ProviderInventory.Type; + export const ServerProviderWorkspaceSnapshot = Schema.Struct({ cwd: TrimmedNonEmptyString, checkedAt: IsoDateTime, slashCommands: Schema.Array(ServerProviderSlashCommand), skills: Schema.Array(ServerProviderSkill), + inventory: Schema.optionalKey(ProviderWorkspaceInventory), }); export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; @@ -223,6 +244,8 @@ export const ServerProvider = Schema.Struct({ // Surfaces in the UI alongside the missing-driver affordance. unavailableReason: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), + // Older servers and cache files do not report inventory completeness. + inventory: Schema.optionalKey(ProviderInventory), slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -235,6 +258,19 @@ export const ServerProvider = Schema.Struct({ }); export type ServerProvider = typeof ServerProvider.Type; +/** Native commands can arrive before a workspace's skill scan completes. */ +export function hasProviderWorkspaceSkills( + provider: ServerProvider | null | undefined, + cwd: string | null | undefined, +): boolean { + return Boolean( + cwd && + provider?.workspaceSnapshots?.some( + (snapshot) => snapshot.cwd === cwd && snapshot.inventory?.skills !== "stale", + ), + ); +} + // Provider status kinds grow over time (ServerProviderState, // ServerProviderAuthStatus, ServerProviderVersionAdvisoryStatus, // ServerProviderUpdateStatus); an older client must not fail the whole config From 8f489464ce44e9cec048f99c85db192c5517b65c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 5 Sep 2026 21:43:16 -0700 Subject: [PATCH 2/6] fix(server): retain partial inventories and clear unavailable workspaces --- .../provider/Layers/OpenCodeProvider.test.ts | 26 ++++++++- .../src/provider/Layers/OpenCodeProvider.ts | 3 +- .../provider/Layers/ProviderRegistry.test.ts | 48 +++++++++++++--- .../src/provider/Layers/ProviderRegistry.ts | 20 +++++-- .../opencodeRuntime.cliParsers.test.ts | 51 +++++++++-------- .../opencodeRuntime.inventory.test.ts | 2 +- apps/server/src/provider/opencodeRuntime.ts | 55 ++++++++++++------- apps/server/src/provider/providerSnapshot.ts | 21 ++++--- .../client-runtime/src/providerSkills.test.ts | 18 ++++++ packages/client-runtime/src/providerSkills.ts | 2 +- 10 files changed, 180 insertions(+), 66 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index d55a2dacd79a..c5b59069baa3 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -245,7 +245,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => Effect.gen(function* () { - runtimeMock.state.inventory = { + const inventory = { providerList: { connected: ["openai"], all: [ @@ -275,6 +275,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { ], }; + runtimeMock.state.inventory = inventory; const snapshot = yield* checkProvider(makeOpenCodeSettings()); const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); @@ -295,6 +296,29 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { agentDescriptor.options.find((option) => option.isDefault === true)?.id, "build", ); + const previous = { + ...snapshot, + driver: ProviderDriverKind.make("opencode"), + instanceId: ProviderInstanceId.make("opencode-work"), + }; + runtimeMock.state.inventory = { ...inventory, agents: undefined }; + const partial = mergeProviderSnapshot(previous, { + ...previous, + ...(yield* checkProvider(makeOpenCodeSettings())), + }); + NodeAssert.equal(partial.inventory?.models, "stale"); + NodeAssert.deepEqual(partial.models, previous.models); + + runtimeMock.state.inventory = { ...inventory, agents: [] }; + const complete = mergeProviderSnapshot(partial, { + ...partial, + ...(yield* checkProvider(makeOpenCodeSettings())), + }); + NodeAssert.equal(complete.inventory?.models, "authoritative"); + NodeAssert.deepEqual( + complete.models[0]?.capabilities?.optionDescriptors?.map((option) => option.id), + ["variant"], + ); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 4fce9aaf568c..a22172a2d622 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -281,7 +281,7 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray { + const workspace = { + cwd: "/workspace", + checkedAt: cachedProvider.checkedAt, + slashCommands: cachedProvider.slashCommands, + skills: cachedProvider.skills, + inventory: AUTHORITATIVE_PROVIDER_INVENTORY, + }; + const previous = { ...cachedProvider, workspaceSnapshots: [workspace] }; + const signedOut = { + ...failedProvider, + inventory: UNAVAILABLE_PROVIDER_INVENTORY, + }; + const cleared = mergeProviderSnapshot(previous, signedOut); + assert.deepStrictEqual(cleared.workspaceSnapshots, []); + assert.deepStrictEqual( + mergeProviderSnapshot(cleared, refreshedProvider).workspaceSnapshots, + [], + ); + const unavailableWorkspace = { + ...workspace, + inventory: UNAVAILABLE_PROVIDER_INVENTORY, + slashCommands: [], + skills: [], + }; + assert.deepStrictEqual( + mergeProviderSnapshot( + { ...signedOut, workspaceSnapshots: [unavailableWorkspace] }, + refreshedProvider, + ).workspaceSnapshots, + [], + ); + }); + it("retains old capabilities only for stale discovered rows", () => { const current = cachedProvider.models[0]!; const nextModel = { ...current, capabilities: null }; @@ -958,7 +992,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); }); - it.effect("does not run provider probes during layer construction", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); @@ -1073,10 +1106,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te } as const satisfies ServerProvider; const pendingScopedProvider = { ...scopedProvider, - inventory: STALE_PROVIDER_INVENTORY, + inventory: { ...STALE_PROVIDER_INVENTORY, slashCommands: "authoritative" }, status: "error", installed: false, - slashCommands: [], + slashCommands: [{ name: "native" }], + skills: [], } as const satisfies ServerProvider; const snapshotCalls = yield* Ref.make(0); const returnPendingSnapshot = yield* Ref.make(true); @@ -1165,10 +1199,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); - assert.deepStrictEqual( - (yield* registry.getProviders)[0]?.workspaceSnapshots, - machineProvider.workspaceSnapshots, - ); + const partialWorkspace = (yield* registry.getProviders)[0]?.workspaceSnapshots?.[0]; + assert.deepStrictEqual(partialWorkspace?.slashCommands, [{ name: "native" }]); + assert.deepStrictEqual(partialWorkspace?.skills, []); + assert.strictEqual(partialWorkspace?.inventory?.skills, "stale"); yield* Ref.set(returnPendingSnapshot, false); const workspaceUpdate = yield* registry.streamChanges.pipe( Stream.runHead, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index fcf340e9be71..eadb2c7dfa69 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -150,8 +150,20 @@ export const mergeProviderSnapshot = ( !previousProvider || previousProvider.instanceId !== nextProvider.instanceId || previousProvider.driver !== nextProvider.driver - ) return nextProvider; + ) + return nextProvider; const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); + // Sign-out and missing installations invalidate workspace discoveries too. + // An unavailable workspace must be scanned again when the provider recovers. + const savedWorkspaces = + nextProvider.inventory?.skills === "unavailable" || + nextProvider.inventory?.slashCommands === "unavailable" + ? [] + : previousProvider.workspaceSnapshots?.filter( + (snapshot) => + snapshot.inventory?.skills !== "unavailable" && + snapshot.inventory?.slashCommands !== "unavailable", + ); // A passed health check no longer needs the unchecked-account message. const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; return { @@ -175,8 +187,8 @@ export const mergeProviderSnapshot = ( ), })), } - : previousProvider.workspaceSnapshots !== undefined - ? { workspaceSnapshots: previousProvider.workspaceSnapshots } + : savedWorkspaces !== undefined + ? { workspaceSnapshots: savedWorkspaces } : {}), }; }; @@ -779,7 +791,7 @@ export const ProviderRegistryLive = Layer.effect( if (!claimed) return yield* Ref.get(providersRef); return yield* instance.snapshotForCwd(input.cwd).pipe( Effect.flatMap((scopedSnapshot) => - scopedSnapshot.inventory?.skills === "stale" + scopedSnapshot.inventory === undefined && scopedSnapshot.status === "error" ? Ref.get(providersRef) : instanceRegistry.getInstance(input.instanceId).pipe( Effect.flatMap((currentInstance) => { diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 1402b57afcb9..14df6361050c 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -28,6 +28,7 @@ describe("parseModelsCliOutput", () => { ].join("\n"); const result = parseModelsCliOutput(stdout); + NodeAssert.ok(result); NodeAssert.equal(result.providers.size, 1); NodeAssert.equal(result.connected.length, 1); NodeAssert.equal(result.connected[0], "anthropic"); @@ -56,6 +57,7 @@ describe("parseModelsCliOutput", () => { ].join("\n"); const result = parseModelsCliOutput(stdout); + NodeAssert.ok(result); NodeAssert.equal(result.providers.size, 2); NodeAssert.equal(result.connected.length, 2); NodeAssert.equal([...result.connected].sort().join(","), "anthropic,openai"); @@ -65,23 +67,19 @@ describe("parseModelsCliOutput", () => { it("handles empty input", () => { const result = parseModelsCliOutput(""); + NodeAssert.ok(result); NodeAssert.equal(result.providers.size, 0); NodeAssert.equal(result.connected.length, 0); }); - it("skips unparseable JSON blocks", () => { - const stdout = [ - "anthropic/claude-sonnet-4-5", - "this is not valid json {{{", - "anthropic/claude-haiku-4-5", - JSON.stringify({ id: "claude-haiku-4-5", providerID: "anthropic", name: "Haiku 4.5" }), - ].join("\n"); - - const result = parseModelsCliOutput(stdout); - NodeAssert.equal(result.providers.size, 1); - const provider = result.providers.get("anthropic")!; - NodeAssert.equal(Object.keys(provider.models).length, 1); - NodeAssert.ok(provider.models["claude-haiku-4-5"]); + it("rejects malformed or incomplete model output", () => { + for (const stdout of [ + 'openai/gpt-test\n{broken\nopenai/gpt-other\n{"name":"Other"}', + "openai/gpt-test", + "not a model inventory", + ]) { + NodeAssert.equal(parseModelsCliOutput(stdout), undefined); + } }); it("handles Windows-style CRLF line endings", () => { @@ -91,6 +89,7 @@ describe("parseModelsCliOutput", () => { "\r\n"; const result = parseModelsCliOutput(stdout); + NodeAssert.ok(result); NodeAssert.equal(result.providers.size, 1); NodeAssert.ok(result.providers.get("anthropic")!.models["claude-sonnet-4-5"]); }); @@ -123,6 +122,7 @@ describe("parseModelsCliOutput", () => { ].join("\n"); const result = parseModelsCliOutput(stdout); + NodeAssert.ok(result); const model = result.providers.get("opencode")!.models["gpt-5.4"]!; NodeAssert.ok(model); NodeAssert.ok(model.capabilities); @@ -146,6 +146,7 @@ describe("parseModelsCliOutput", () => { ].join("\n"); const result = parseModelsCliOutput(stdout); + NodeAssert.ok(result); NodeAssert.equal(result.providers.size, 1); NodeAssert.deepEqual([...result.connected], ["openrouter"]); const provider = result.providers.get("openrouter")!; @@ -165,6 +166,7 @@ describe("parseAgentListCliOutput", () => { ].join("\n"); const result = parseAgentListCliOutput(stdout); + NodeAssert.ok(result); NodeAssert.equal(result.length, 1); NodeAssert.equal(result[0]!.name, "build"); NodeAssert.equal(result[0]!.mode, "primary"); @@ -182,6 +184,7 @@ describe("parseAgentListCliOutput", () => { ].join("\n"); const result = parseAgentListCliOutput(stdout); + NodeAssert.ok(result); NodeAssert.equal(result.length, 3); NodeAssert.equal(result[0]!.name, "build"); NodeAssert.equal(result[0]!.mode, "primary"); @@ -193,20 +196,17 @@ describe("parseAgentListCliOutput", () => { it("handles empty input", () => { const result = parseAgentListCliOutput(""); - NodeAssert.equal(result.length, 0); + NodeAssert.deepEqual(result, []); }); - it("skips agents with unparseable permission JSON", () => { - const stdout = [ + it("rejects malformed or incomplete agent output", () => { + for (const stdout of [ + "build (primary)\n not valid json {\nexplore (subagent)\n[]", "build (primary)", - " not valid json {", - "explore (subagent)", - " " + JSON.stringify([{ permission: "read", action: "allow", pattern: "*" }]), - ].join("\n"); - - const result = parseAgentListCliOutput(stdout); - NodeAssert.equal(result.length, 1); - NodeAssert.equal(result[0]!.name, "explore"); + "not an agent inventory", + ]) { + NodeAssert.equal(parseAgentListCliOutput(stdout), undefined); + } }); it("handles real-world permission blocks with nested paths", () => { @@ -222,6 +222,7 @@ describe("parseAgentListCliOutput", () => { const stdout = ["build (primary)", " " + JSON.stringify(permissions)].join("\n"); const result = parseAgentListCliOutput(stdout); + NodeAssert.ok(result); NodeAssert.equal(result.length, 1); NodeAssert.equal(result[0]!.permission.length, 3); NodeAssert.equal(result[0]!.permission[0]!.action, "allow"); @@ -237,6 +238,7 @@ describe("parseAgentListCliOutput", () => { ].join("\n"); const result = parseAgentListCliOutput(stdout); + NodeAssert.ok(result); NodeAssert.equal(result.length, 2); NodeAssert.equal(result[0]!.name, "code reviewer"); NodeAssert.equal(result[0]!.mode, "subagent"); @@ -253,6 +255,7 @@ describe("parseAgentListCliOutput", () => { ].join("\n"); const result = parseAgentListCliOutput(stdout); + NodeAssert.ok(result); NodeAssert.equal(result[0]!.hidden, true); NodeAssert.equal(result[1]!.hidden, false); }); diff --git a/apps/server/src/provider/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 9475dbb20755..34827ec1343b 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -81,7 +81,7 @@ it.layer(testLayer)("OpenCodeRuntime inventory", (it) => { const inventory = yield* runtime.loadOpenCodeInventory(client); NodeAssert.deepEqual(inventory.providerList.connected, ["openai"]); - NodeAssert.deepEqual(inventory.agents, []); + NodeAssert.equal(inventory.agents, undefined); NodeAssert.deepEqual(inventory.skills, []); }), ); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index a342ea51643d..a1ccc7371437 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -185,7 +185,7 @@ export interface OpenCodeCommandResult { export interface OpenCodeInventory { readonly providerList: ProviderListResponse; - readonly agents: ReadonlyArray; + readonly agents: ReadonlyArray | undefined; readonly skills: ReadonlyArray | undefined; } @@ -290,23 +290,26 @@ const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); /** @internal */ -export function parseModelsCliOutput(stdout: string): { - readonly providers: ReadonlyMap< - string, - { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } - >; - readonly connected: ReadonlyArray; -} { +export function parseModelsCliOutput(stdout: string): + | { + readonly providers: ReadonlyMap< + string, + { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } } + >; + readonly connected: ReadonlyArray; + } + | undefined { const providers = new Map< string, { id: string; name: string; models: { [key: string]: Model } } >(); + let complete = true; const lines = stdout.split("\n"); let currentSlug: string | null = null; const jsonLines: Array = []; const flushModel = () => { - if (currentSlug !== null && jsonLines.length > 0) { + if (currentSlug !== null) { const jsonStr = jsonLines.join("\n").trim(); if (jsonStr.length > 0) { try { @@ -323,8 +326,10 @@ export function parseModelsCliOutput(stdout: string): { provider.models[modelID] = model; } } catch { - // Skip unparseable model JSON + complete = false; } + } else { + complete = false; } } currentSlug = null; @@ -344,16 +349,19 @@ export function parseModelsCliOutput(stdout: string): { currentSlug = slugMatch[1]!; } else if (currentSlug !== null) { jsonLines.push(line); + } else if (line.trim().length > 0) { + complete = false; } } flushModel(); - return { providers, connected: [...providers.keys()] }; + return complete ? { providers, connected: [...providers.keys()] } : undefined; } /** @internal */ -export function parseAgentListCliOutput(stdout: string): ReadonlyArray { +export function parseAgentListCliOutput(stdout: string): ReadonlyArray | undefined { const agents: Array = []; + let complete = true; const lines = stdout.split("\n"); let currentHeader: { name: string; mode: string } | null = null; const blockLines: Array = []; @@ -372,8 +380,10 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { options: {}, }); } catch { - // Skip unparseable agent + complete = false; } + } else { + complete = false; } } currentHeader = null; @@ -387,11 +397,13 @@ export function parseAgentListCliOutput(stdout: string): ReadonlyArray { currentHeader = { name: match[1]!, mode: match[2]! }; } else if (currentHeader !== null) { blockLines.push(line); + } else if (line.trim().length > 0) { + complete = false; } } flushAgent(); - return agents; + return complete ? agents : undefined; } /** @internal */ @@ -898,8 +910,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const loadAgents = (client: OpencodeClient) => runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe( - Effect.map((result) => result.data ?? []), - Effect.orElseSucceed((): ReadonlyArray => []), + Effect.map((result) => result.data), + Effect.orElseSucceed(() => undefined), ); const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => @@ -998,6 +1010,12 @@ const makeOpenCodeRuntime = Effect.gen(function* () { } const parsed = parseModelsCliOutput(modelsResult.value.stdout); + if (parsed === undefined) { + return yield* new OpenCodeRuntimeError({ + operation: "loadInventoryFromCli", + detail: "OpenCode did not return a complete model inventory.", + }); + } const connected = [...parsed.connected]; const allProviders: ProviderListResponse["all"] = [...parsed.providers.values()].map( (provider) => ({ @@ -1010,9 +1028,8 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }), ); - // Agent and skill metadata enrich the provider snapshot but are not required - // for an authoritative model inventory, so either may degrade to an empty list. - let agents: ReadonlyArray = []; + // Keep failed metadata distinct from a successful empty inventory. + let agents: ReadonlyArray | undefined; if (agentsResult._tag === "Success" && agentsResult.value.code === 0) { agents = parseAgentListCliOutput(agentsResult.value.stdout); } diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index b256c63e7f38..1ded47fc0e14 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -327,14 +327,19 @@ export function mergeProviderModels( const previousBySlug = new Map(previousBuiltIns.map((model) => [model.slug, model])); const models = next.map((model) => { const previousModel = previousBySlug.get(model.slug); - if ( - model.isCustom || - !previousModel || - (model.capabilities?.optionDescriptors?.length ?? 0) > 0 || - (previousModel.capabilities?.optionDescriptors?.length ?? 0) === 0 - ) - return model; - return { ...model, capabilities: previousModel.capabilities }; + if (model.isCustom || !previousModel?.capabilities) return model; + if (!model.capabilities) return { ...model, capabilities: previousModel.capabilities }; + return { + ...model, + capabilities: { + ...model.capabilities, + optionDescriptors: retainMissingItems( + previousModel.capabilities.optionDescriptors ?? [], + model.capabilities.optionDescriptors ?? [], + (option) => option.id, + ), + }, + }; }); return retainMissingItems(previousBuiltIns, models, (model) => model.slug); } diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 5e581dba2916..044ef488b0a3 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -289,6 +289,24 @@ describe("workspace provider snapshots", () => { expect(hasProviderWorkspaceSkills(pending, "/workspace/project-a")).toBe(true); }); + it("keeps the previous workspace commands after a failed refresh", () => { + const stale = { + ...provider, + workspaceSnapshots: [ + { + ...provider.workspaceSnapshots[0]!, + inventory: { slashCommands: "stale", skills: "stale" }, + }, + ], + } satisfies ServerProvider; + expect(resolveProviderSlashCommandsForCwd(stale, "/workspace/project-a")).toEqual([ + { name: "project" }, + ]); + expect(resolveProviderSkillsForCwd(stale, "/workspace/project-a")).toEqual( + provider.workspaceSnapshots[0]!.skills, + ); + }); + it("uses the cwd snapshot after a provider session has populated it", () => { expect(hasProviderWorkspaceSkills(provider, "/workspace/project-a")).toBe(true); expect(resolveProviderSkillsForCwd(provider, "/workspace/project-a")).toEqual([ diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index 6ae8f5fa811b..ddf158dcff45 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -126,7 +126,7 @@ export function resolveProviderSlashCommandsForCwd( cwd: string | null | undefined, ): ServerProvider["slashCommands"] { const workspace = resolveProviderWorkspaceSnapshot(provider, cwd); - return workspace?.inventory?.slashCommands === "stale" + return workspace?.inventory?.slashCommands === "stale" && workspace.slashCommands.length === 0 ? provider.slashCommands : (workspace?.slashCommands ?? provider.slashCommands); } From 600bcabb237385241409fd28a9638c50c08970af Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 5 Sep 2026 21:50:43 -0700 Subject: [PATCH 3/6] fix(server): separate model options from inventory membership --- .../src/provider/Layers/CursorProvider.test.ts | 1 + apps/server/src/provider/Layers/CursorProvider.ts | 13 ++++++++----- .../server/src/provider/Layers/GrokProvider.test.ts | 1 + apps/server/src/provider/Layers/GrokProvider.ts | 2 +- .../src/provider/Layers/OpenCodeProvider.test.ts | 6 ++++-- apps/server/src/provider/Layers/OpenCodeProvider.ts | 3 ++- apps/server/src/provider/Layers/ProviderRegistry.ts | 1 + apps/server/src/provider/providerSnapshot.ts | 9 ++++++--- apps/server/src/provider/providerStatusCache.ts | 1 + packages/contracts/src/server.ts | 2 ++ 10 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index a647ea546374..89e78a2ec133 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -528,6 +528,7 @@ describe("buildCursorProviderSnapshot", () => { ).toMatchObject({ status: "error", message: `Cursor Agent is not authenticated. Run \`agent login\` and try again. ${cursorAcpDiscoveryFailedMessage}`, + inventory: { models: "unavailable", slashCommands: "unavailable", skills: "unavailable" }, models: [ { slug: "claude-sonnet-4-6", diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 266eefdac3df..506fc88d45b5 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -666,11 +666,14 @@ export function buildCursorProviderSnapshot(input: { ), compaction: CURSOR_COMPACTION, probe: { - inventory: { - ...AUTHORITATIVE_PROVIDER_INVENTORY, - models: input.discoveredModels === undefined ? "stale" : "authoritative", - skills: "stale", - }, + inventory: + input.parsed.auth.status === "unauthenticated" + ? UNAVAILABLE_PROVIDER_INVENTORY + : { + ...AUTHORITATIVE_PROVIDER_INVENTORY, + models: input.discoveredModels === undefined ? "stale" : "authoritative", + skills: "stale", + }, installed: true, version: input.parsed.version, status: diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index c751919b189a..e9a760cf7d8f 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -409,6 +409,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { expect(snapshot.status).toBe("error"); expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.inventory?.models).toBe("unavailable"); expect(snapshot.message).toContain("grok login"); expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); }), diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index f018f1d94bf4..b0a011c9f00f 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -504,7 +504,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func models, skills: skills ?? [], probe: { - inventory: { ...inventory, slashCommands: "unavailable" }, + inventory: { ...inventory, models: "unavailable", slashCommands: "unavailable" }, installed: true, version, status: "error", diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index c5b59069baa3..8fe1c7667a58 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -300,14 +300,16 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { ...snapshot, driver: ProviderDriverKind.make("opencode"), instanceId: ProviderInstanceId.make("opencode-work"), + models: [...snapshot.models, { ...model, slug: "retired-model" }], }; runtimeMock.state.inventory = { ...inventory, agents: undefined }; const partial = mergeProviderSnapshot(previous, { ...previous, ...(yield* checkProvider(makeOpenCodeSettings())), }); - NodeAssert.equal(partial.inventory?.models, "stale"); - NodeAssert.deepEqual(partial.models, previous.models); + NodeAssert.equal(partial.inventory?.models, "authoritative"); + NodeAssert.equal(partial.inventory?.modelOptions, "stale"); + NodeAssert.deepEqual(partial.models, snapshot.models); runtimeMock.state.inventory = { ...inventory, agents: [] }; const complete = mergeProviderSnapshot(partial, { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index a22172a2d622..376c2c303a7d 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -538,7 +538,8 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu probe: { inventory: { ...AUTHORITATIVE_PROVIDER_INVENTORY, - models: inventoryExit.value.inventory.agents === undefined ? "stale" : "authoritative", + modelOptions: + inventoryExit.value.inventory.agents === undefined ? "stale" : "authoritative", skills: inventoryExit.value.inventory.skills === undefined ? "stale" : "authoritative", }, installed: true, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index eadb2c7dfa69..61a69775ac0c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -173,6 +173,7 @@ export const mergeProviderSnapshot = ( previousProvider.models, nextProvider.models, nextProvider.inventory?.models ?? "stale", + nextProvider.inventory?.modelOptions, ), ...mergeProviderWorkspaceInventories(previousProvider, nextProvider), ...(nextProvider.workspaceSnapshots !== undefined diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 1ded47fc0e14..d9b729dfd7b1 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -320,14 +320,15 @@ export function mergeProviderModels( previous: ReadonlyArray, next: ReadonlyArray, state: ProviderInventoryState, + optionState: ProviderInventoryState = state, ): ReadonlyArray { - if (state !== "stale") return next; + if (state !== "stale" && optionState !== "stale") return next; const previousBuiltIns = previous.filter((model) => !model.isCustom); const previousBySlug = new Map(previousBuiltIns.map((model) => [model.slug, model])); const models = next.map((model) => { const previousModel = previousBySlug.get(model.slug); - if (model.isCustom || !previousModel?.capabilities) return model; + if (optionState !== "stale" || model.isCustom || !previousModel?.capabilities) return model; if (!model.capabilities) return { ...model, capabilities: previousModel.capabilities }; return { ...model, @@ -341,7 +342,9 @@ export function mergeProviderModels( }, }; }); - return retainMissingItems(previousBuiltIns, models, (model) => model.slug); + return state === "stale" + ? retainMissingItems(previousBuiltIns, models, (model) => model.slug) + : models; } export function mergeProviderWorkspaceInventories( diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index c00fdce7f7a3..979c0b3cca3d 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -74,6 +74,7 @@ export const hydrateCachedProvider = (input: { input.cachedProvider.models, input.fallbackProvider.models, input.fallbackProvider.inventory?.models ?? "stale", + input.fallbackProvider.inventory?.modelOptions, ), installed: input.cachedProvider.installed, version: input.cachedProvider.version, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9e5b6edf7408..429ee823f154 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -131,6 +131,8 @@ export type ProviderWorkspaceInventory = typeof ProviderWorkspaceInventory.Type; export const ProviderInventory = Schema.Struct({ models: ProviderInventoryState, + // A model list can complete while its option metadata fails to load. + modelOptions: Schema.optionalKey(ProviderInventoryState), ...ProviderWorkspaceInventory.fields, }); export type ProviderInventory = typeof ProviderInventory.Type; From 649542bdcb34e0d6147cd87bde4da5121fedd81b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 5 Sep 2026 22:01:01 -0700 Subject: [PATCH 4/6] test(server): supply inventory state to compaction fixture --- apps/server/src/provider/providerSnapshot.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 2900730ee8b8..c8690d8d6cef 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -11,6 +11,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildServerProvider, + AUTHORITATIVE_PROVIDER_INVENTORY, isCommandMissingCause, providerModelsFromSettings, spawnAndCollect, @@ -39,6 +40,7 @@ describe("compaction advertisement", () => { review, ], probe: { + inventory: AUTHORITATIVE_PROVIDER_INVENTORY, installed: true, version: null, status: "ready", From c7ced553cd5c81fcd9bbbbde8d532b76ed55344e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 5 Sep 2026 22:08:55 -0700 Subject: [PATCH 5/6] fix(server): retry unavailable workspace discovery --- .../src/provider/Layers/GrokProvider.test.ts | 64 ++++++++++++------- .../src/provider/Layers/GrokProvider.ts | 12 ++-- .../client-runtime/src/providerSkills.test.ts | 14 ++++ packages/contracts/src/server.ts | 5 +- 4 files changed, 67 insertions(+), 28 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index e9a760cf7d8f..a336ec56453d 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -336,7 +336,10 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { // A stand-in for the Grok CLI: `--version` and `models` print canned text, // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. - const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) => + const writeFakeGrokCli = (input: { + readonly modelsOutput: string; + readonly acp: boolean | "without-models"; + }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" }); @@ -355,7 +358,19 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { " process.exit(0);", "}", 'if (process.argv[2] !== "agent") process.exit(1);', - ...(input.acp ? [execScriptSource({ scriptPath: mockAgentPath })] : ["process.exit(3);"]), + ...(input.acp === "without-models" + ? [ + 'import { createInterface } from "node:readline";', + 'createInterface({ input: process.stdin }).on("line", (line) => {', + " const request = JSON.parse(line);", + " if (request.id === undefined) return;", + " const result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] };", + ' process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\\n");', + "});", + ] + : input.acp + ? [execScriptSource({ scriptPath: mockAgentPath })] + : ["process.exit(3);"]), "", ].join("\n"), }); @@ -417,27 +432,30 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { it.effect("falls back to CLI-listed models with a warning when ACP initialize fails", () => Effect.gen(function* () { - const snapshot = yield* Effect.scoped( - Effect.gen(function* () { - const grokPath = yield* writeFakeGrokCli({ - modelsOutput: LOGGED_IN_MODELS_OUTPUT, - acp: false, - }); - return yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - { ...process.env, XAI_API_KEY: "" }, - ); - }), - ); - - expect(snapshot.status).toBe("warning"); - expect(snapshot.installed).toBe(true); - expect(snapshot.auth.status).toBe("authenticated"); - expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ - ["grok-4.6", true], - ["grok-4.5", false], - ]); - expect(snapshot.message).toContain("ACP initialize failed"); + for (const acp of [false, "without-models"] as const) { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_IN_MODELS_OUTPUT, + acp, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "" }, + ); + }), + ); + + expect(snapshot.status).toBe("warning"); + expect(snapshot.installed).toBe(true); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ + ["grok-4.6", true], + ["grok-4.5", false], + ]); + expect(snapshot.message).toContain("ACP model discovery did not complete"); + expect(snapshot.inventory?.models).toBe("stale"); + } }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index b0a011c9f00f..c9c052d40d1b 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -477,10 +477,14 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func Effect.exit, ); const acpModels = Exit.isSuccess(acpExit) ? Option.getOrUndefined(acpExit.value) : undefined; - const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value); + const acpFailed = acpModels === undefined; if (acpFailed) { - yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", { - errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout", + yield* Effect.logWarning("Grok ACP model discovery did not complete.", { + errorTag: Exit.isFailure(acpExit) + ? causeErrorTag(acpExit.cause) + : Option.isNone(acpExit.value) + ? "Timeout" + : "MissingModelMetadata", }); } @@ -531,7 +535,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func ...(acpFailed ? { message: - "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", + "Grok CLI is installed but ACP model discovery did not complete. Model options may be incomplete.", } : {}), }, diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 044ef488b0a3..30d8e359ea9e 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -270,6 +270,20 @@ describe("workspace provider snapshots", () => { } satisfies ServerProvider; expect(hasProviderWorkspaceSkills(completed, cwd)).toBe(true); expect(resolveProviderSkillsForCwd(completed, cwd)).toEqual([]); + expect( + hasProviderWorkspaceSkills( + { + ...completed, + workspaceSnapshots: [ + { + ...completed.workspaceSnapshots[0]!, + inventory: { slashCommands: "unavailable", skills: "unavailable" }, + }, + ], + }, + cwd, + ), + ).toBe(false); }); it("keeps current machine commands until workspace commands are known", () => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 429ee823f154..56e1b2e30ab7 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -268,7 +268,10 @@ export function hasProviderWorkspaceSkills( return Boolean( cwd && provider?.workspaceSnapshots?.some( - (snapshot) => snapshot.cwd === cwd && snapshot.inventory?.skills !== "stale", + (snapshot) => + snapshot.cwd === cwd && + snapshot.inventory?.skills !== "stale" && + snapshot.inventory?.skills !== "unavailable", ), ); } From c9fbc743aff126d7e996501b0bcf36b69f08223b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 5 Sep 2026 22:19:35 -0700 Subject: [PATCH 6/6] fix(server): reject stale account scans and retain usable skills --- .../src/provider/Drivers/AntigravityDriver.ts | 31 ++++++++------- .../Layers/AntigravityProvider.test.ts | 36 +++++++++++++++++- .../provider/Layers/AntigravityProvider.ts | 17 +++++++-- .../provider/Layers/ProviderRegistry.test.ts | 9 +++++ .../src/provider/Layers/ProviderRegistry.ts | 38 +++++++++++++------ 5 files changed, 102 insertions(+), 29 deletions(-) diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 65a8c97fe668..29a95a397069 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -379,20 +379,25 @@ export const AntigravityDriver: ProviderDriver !enabled ? provider.snapshot.getSnapshot - : discoverAntigravitySkills({ cwd, userHome }).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER, - instanceId, - detail: "Could not read Antigravity workspace skills.", - cause, - }), + : provider + .snapshotForCwd( + cwd, + discoverAntigravitySkills({ cwd, userHome }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER, + instanceId, + detail: "Could not read Antigravity workspace skills.", + cause, + }), + ), ), - ), adapter, textGeneration, auth: authFlow.controller, diff --git a/apps/server/src/provider/Layers/AntigravityProvider.test.ts b/apps/server/src/provider/Layers/AntigravityProvider.test.ts index 3ff4411c2ee7..2279bef54ea8 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.test.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.test.ts @@ -6,6 +6,7 @@ import { ProviderDriverKind, ProviderInstanceId, ProviderSetupError, + type ServerProvider, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -635,7 +636,10 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { expect( hasProviderWorkspaceSkills(yield* harness.provider.snapshot.getSnapshot, "/workspace"), ).toBe(false); - const discovered = yield* harness.provider.snapshotForCwd("/workspace", skills); + const discovered = yield* harness.provider.snapshotForCwd( + "/workspace", + Effect.succeed(skills), + ); expect(discovered.skills).toEqual(skills); yield* harness.provider.snapshot.refresh; const afterRefresh = yield* harness.provider.snapshot.getSnapshot; @@ -657,6 +661,36 @@ it.layer(testLayer)("Antigravity provider snapshots", (it) => { ), ); + it.effect("ignores workspace scans from a previous account revision", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness(); + const scanStarted = yield* Deferred.make(); + const scanResult = yield* Deferred.make(); + yield* harness.provider.onSessionStarted(started, "/workspace"); + const scanning = yield* harness.provider + .snapshotForCwd( + "/workspace", + Effect.gen(function* () { + yield* Deferred.succeed(scanStarted, undefined); + return yield* Deferred.await(scanResult); + }), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(scanStarted); + yield* harness.provider.onSignedOut; + yield* harness.provider.onSessionStarted(started, "/workspace"); + yield* Deferred.succeed(scanResult, [ + { name: "previous-account", path: "/old-account/SKILL.md", enabled: true }, + ]); + expect((yield* Fiber.join(scanning)).skills).toEqual([]); + expect( + (yield* harness.provider.snapshot.getSnapshot).workspaceSnapshots?.[0]?.skills, + ).toEqual([]); + }), + ), + ); + it.effect("bounds workspace metadata without starting sessions for workspace lookup", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/AntigravityProvider.ts b/apps/server/src/provider/Layers/AntigravityProvider.ts index 3cd803e63803..defbf87e9417 100644 --- a/apps/server/src/provider/Layers/AntigravityProvider.ts +++ b/apps/server/src/provider/Layers/AntigravityProvider.ts @@ -401,13 +401,22 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func }); // Disk discovery and native callbacks update the same workspace entry. - const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( + const snapshotForCwd = Effect.fn("AntigravityProvider.snapshotForCwd")(function* ( cwd: string, - skills?: ServerProvider["skills"], + discoverSkills?: Effect.Effect, ) { - if (skills !== undefined) { + const before = yield* SubscriptionRef.get(metadata); + if (before.draft.auth.status === "unauthenticated") return yield* getSnapshot; + if (discoverSkills !== undefined) { + const skills = yield* discoverSkills; const checkedAt = DateTime.formatIso(yield* DateTime.now); yield* SubscriptionRef.update(metadata, (state) => { + if ( + state.authRevision !== before.authRevision || + state.draft.auth.status === "unauthenticated" + ) { + return state; + } const workspaces = state.draft.workspaceSnapshots ?? []; const previous = workspaces.find((entry) => entry.cwd === cwd); const workspace = { @@ -443,7 +452,7 @@ export const makeAntigravityProvider = Effect.fn("makeAntigravityProvider")(func ...(snapshot.inventory ?? STALE_PROVIDER_INVENTORY), slashCommands: workspace?.inventory?.slashCommands ?? snapshot.inventory?.slashCommands ?? "stale", - skills: workspace?.inventory?.skills ?? "stale", + skills: workspace?.inventory?.skills ?? snapshot.inventory?.skills ?? "stale", }, }; }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index d7b5a0c6b5d3..b97981017ff3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -721,6 +721,15 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te inventory: AUTHORITATIVE_PROVIDER_INVENTORY, }; const previous = { ...cachedProvider, workspaceSnapshots: [workspace] }; + for (const unavailable of ["skills", "slashCommands"] as const) { + const other = unavailable === "skills" ? "slashCommands" : "skills"; + const partial = mergeProviderSnapshot(previous, { + ...refreshedProvider, + inventory: { ...AUTHORITATIVE_PROVIDER_INVENTORY, [unavailable]: "unavailable" }, + }); + assert.deepStrictEqual(partial.workspaceSnapshots?.[0]?.[unavailable], []); + assert.deepStrictEqual(partial.workspaceSnapshots?.[0]?.[other], workspace[other]); + } const signedOut = { ...failedProvider, inventory: UNAVAILABLE_PROVIDER_INVENTORY, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 61a69775ac0c..dbd366ad0306 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -153,17 +153,33 @@ export const mergeProviderSnapshot = ( ) return nextProvider; const savedAccount = carrySavedAntigravityAccount(previousProvider, nextProvider); - // Sign-out and missing installations invalidate workspace discoveries too. - // An unavailable workspace must be scanned again when the provider recovers. - const savedWorkspaces = - nextProvider.inventory?.skills === "unavailable" || - nextProvider.inventory?.slashCommands === "unavailable" - ? [] - : previousProvider.workspaceSnapshots?.filter( - (snapshot) => - snapshot.inventory?.skills !== "unavailable" && - snapshot.inventory?.slashCommands !== "unavailable", - ); + // Invalidate each workspace inventory without discarding the other one. + const savedWorkspaces = previousProvider.workspaceSnapshots?.flatMap((snapshot) => { + const skillsUnavailable = nextProvider.inventory?.skills === "unavailable"; + const commandsUnavailable = nextProvider.inventory?.slashCommands === "unavailable"; + if ( + (skillsUnavailable && commandsUnavailable) || + (snapshot.inventory?.skills === "unavailable" && + snapshot.inventory.slashCommands === "unavailable") + ) + return []; + if (!skillsUnavailable && !commandsUnavailable) return [snapshot]; + return [ + { + ...snapshot, + skills: skillsUnavailable ? [] : snapshot.skills, + slashCommands: commandsUnavailable ? [] : snapshot.slashCommands, + inventory: { + skills: skillsUnavailable + ? "unavailable" + : (snapshot.inventory?.skills ?? "authoritative"), + slashCommands: commandsUnavailable + ? "unavailable" + : (snapshot.inventory?.slashCommands ?? "authoritative"), + }, + } satisfies NonNullable[number], + ]; + }); // A passed health check no longer needs the unchecked-account message. const { message: _uncheckedMessage, ...nextWithoutMessage } = nextProvider; return {