From 68ddf7285757518dcaea07e79fb136d9a202c520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 12:08:54 +0100 Subject: [PATCH 01/11] Add provider skill listing to workspace - Propagate cwd through provider status probes - Add server RPC for workspace skill discovery - Load workspace skills in the composer UI --- .agents/skills/bogus/SKILL.md | 6 + .../src/provider/Drivers/CodexDriver.ts | 8 +- .../src/provider/Drivers/CursorDriver.ts | 7 +- .../server/src/provider/Drivers/GrokDriver.ts | 7 +- .../src/provider/Layers/CodexProvider.ts | 38 ++++- .../provider/Layers/CursorProvider.test.ts | 31 ++-- .../src/provider/Layers/CursorProvider.ts | 16 ++- .../src/provider/Layers/GrokProvider.test.ts | 3 + .../src/provider/Layers/GrokProvider.ts | 6 +- .../provider/Layers/ProviderRegistry.test.ts | 74 +++++----- apps/server/src/ws.ts | 99 +++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 24 +++- .../environments/runtime/connection.test.ts | 1 + .../runtime/service.savedEnvironments.test.ts | 1 + .../service.threadSubscriptions.test.ts | 1 + .../src/lib/providerWorkspaceSkillsState.ts | 135 ++++++++++++++++++ apps/web/src/localApi.ts | 4 + packages/client-runtime/src/wsRpcClient.ts | 3 + packages/contracts/src/ipc.ts | 5 + packages/contracts/src/rpc.ts | 11 ++ packages/contracts/src/server.ts | 19 +++ 21 files changed, 440 insertions(+), 59 deletions(-) create mode 100644 .agents/skills/bogus/SKILL.md create mode 100644 apps/web/src/lib/providerWorkspaceSkillsState.ts diff --git a/.agents/skills/bogus/SKILL.md b/.agents/skills/bogus/SKILL.md new file mode 100644 index 00000000000..506636b7b26 --- /dev/null +++ b/.agents/skills/bogus/SKILL.md @@ -0,0 +1,6 @@ +--- +name: bogus +description: A useless skill to test only skill discovery. Can be committed, will remove manually before merging to main. +--- + +Nothing. diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 441edda479f..984ca6c1bc3 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -112,6 +112,7 @@ export const CodexDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); @@ -159,7 +160,12 @@ export const CodexDriver: ProviderDriver = { // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits // `makeManagedServerProvider.checkProvider`'s `R = never`. - const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( + const checkProvider = checkCodexProviderStatus( + effectiveConfig, + serverConfig.cwd, + undefined, + processEnv, + ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index ba532864c45..e9eb1011a45 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -99,6 +99,7 @@ export const CursorDriver: ProviderDriver = { const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -123,7 +124,11 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkCursorProviderStatus( + effectiveConfig, + serverConfig.cwd, + processEnv, + ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index ab01439ffd3..ef5871437ef 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -81,6 +81,7 @@ export const GrokDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -105,7 +106,11 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkGrokProviderStatus( + effectiveConfig, + serverConfig.cwd, + processEnv, + ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 89d7421b232..e01db62f7a0 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -267,6 +267,41 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( return models; }); +export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; +}) { + const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const clientContext = yield* Layer.build( + CodexClient.layerCommand({ + command: input.binaryPath, + args: ["app-server"], + cwd: input.cwd, + env: { + ...(input.environment ?? process.env), + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }, + }), + ); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), + ); + + yield* client.request("initialize", buildCodexInitializeParams()); + yield* client.notify("initialized", undefined); + const accountResponse = yield* client.request("account/read", {}); + if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { + return []; + } + + const response = yield* client.request("skills/list", { + cwds: [input.cwd], + }); + return parseCodexSkillsListResponse(response, input.cwd); +}); + export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { return { clientInfo: { @@ -438,6 +473,7 @@ function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( codexSettings: CodexSettings, + cwd: string, probe: (input: { readonly binaryPath: string; readonly homePath?: string; @@ -478,7 +514,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const probeResult = yield* probe({ binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, - cwd: process.cwd(), + cwd, customModels: codexSettings.customModels, environment, }).pipe( diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 60a7312eea3..b7de1ee6cb4 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -422,6 +422,7 @@ describe("checkCursorProviderStatus", () => { apiEndpoint: "", customModels: [], }, + process.cwd(), { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, @@ -444,12 +445,15 @@ describe("discoverCursorModelsViaAcp", () => { const wrapperPath = await runNode(makeMockAgentWrapper()); const models = await Effect.runPromise( - discoverCursorModelsViaAcp({ - enabled: true, - binaryPath: wrapperPath, - apiEndpoint: "", - customModels: [], - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + discoverCursorModelsViaAcp( + { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }, + process.cwd(), + ).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); expect(models.map((model) => model.slug)).toEqual([ @@ -466,12 +470,15 @@ describe("discoverCursorModelsViaAcp", () => { ); await Effect.runPromise( - discoverCursorModelsViaAcp({ - enabled: true, - binaryPath: wrapperPath, - apiEndpoint: "", - customModels: [], - }).pipe(Effect.provide(NodeServices.layer)), + discoverCursorModelsViaAcp( + { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }, + process.cwd(), + ).pipe(Effect.provide(NodeServices.layer)), ); const exitLog = await runNode(waitForFileContent(exitLogPath)); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index facdb5a5ff1..21cacc81e8a 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -394,6 +394,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( const makeCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, ) => Effect.gen(function* () { @@ -406,10 +407,10 @@ const makeCursorAcpProbeRuntime = ( ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), "acp", ], - cwd: process.cwd(), + cwd, env: environment, }, - cwd: process.cwd(), + cwd, clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, authMethodId: "cursor_login", clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, @@ -420,10 +421,11 @@ const makeCursorAcpProbeRuntime = ( const withCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, + cwd: string, useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, environment: NodeJS.ProcessEnv = process.env, ) => - makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( + makeCursorAcpProbeRuntime(cursorSettings, cwd, environment).pipe( Effect.flatMap(useRuntime), Effect.scoped, ); @@ -542,10 +544,12 @@ export function resolveCursorAcpConfigUpdates( const discoverCursorModelsViaListAvailableModels = ( cursorSettings: CursorSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, ) => withCursorAcpProbeRuntime( cursorSettings, + cwd, (acp) => Effect.gen(function* () { yield* acp.start(); @@ -558,8 +562,9 @@ const discoverCursorModelsViaListAvailableModels = ( export const discoverCursorModelsViaAcp = ( cursorSettings: CursorSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, -) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); +) => discoverCursorModelsViaListAvailableModels(cursorSettings, cwd, environment); export function getCursorFallbackModels( cursorSettings: Pick, @@ -967,6 +972,7 @@ const runCursorAboutCommand = ( export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return< ServerProviderDraft, @@ -1062,7 +1068,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( let discoveryWarning: string | undefined; if (parsed.auth.status !== "unauthenticated") { const discoveryExit = yield* Effect.exit( - discoverCursorModelsViaAcp(cursorSettings, environment).pipe( + discoverCursorModelsViaAcp(cursorSettings, cwd, environment).pipe( Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), ), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 75d0982565e..71f01fdbaf1 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -44,6 +44,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { enabled: true, binaryPath: "/definitely/not/installed/grok-binary", }), + process.cwd(), ); expect(snapshot.enabled).toBe(true); expect(snapshot.installed).toBe(false); @@ -68,6 +69,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + process.cwd(), ); }), ); @@ -95,6 +97,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + process.cwd(), ); }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index bead8b1a407..dfce0b35111 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -131,6 +131,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( const discoverGrokModelsViaAcp = ( grokSettings: GrokSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, ) => Effect.gen(function* () { @@ -139,7 +140,7 @@ const discoverGrokModelsViaAcp = ( grokSettings, environment, childProcessSpawner, - cwd: process.cwd(), + cwd, clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, }); const started = yield* acp.start(); @@ -162,6 +163,7 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return { const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -244,7 +246,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( + const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, cwd, environment).pipe( Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit, ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 56b80f6c4a2..2a38a1db2df 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -304,21 +304,28 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T describe("checkCodexProviderStatus", () => { it.effect("uses the app-server account and model list for provider status", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - skills: [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ], - }), - ), + let observedCwd: string | null = null; + const status = yield* checkCodexProviderStatus( + defaultCodexSettings, + "/tmp/t3-code-cwd", + (input) => { + observedCwd = input.cwd; + return Effect.succeed( + makeCodexProbeSnapshot({ + skills: [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ], + }), + ); + }, ); + assert.strictEqual(observedCwd, "/tmp/t3-code-cwd"); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.installed, true); assert.strictEqual(status.version, "1.0.0"); @@ -348,7 +355,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, process.cwd(), () => Effect.succeed( makeCodexProbeSnapshot({ account: { @@ -372,15 +379,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T "returns ready with unknown auth when app-server does not require OpenAI auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: null, - requiresOpenaiAuth: false, - }, - }), - ), + const status = yield* checkCodexProviderStatus( + defaultCodexSettings, + process.cwd(), + () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: false, + }, + }), + ), ); assert.strictEqual(status.status, "ready"); @@ -390,7 +400,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("returns an api key label for codex api key auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, process.cwd(), () => Effect.succeed( makeCodexProbeSnapshot({ account: { @@ -410,7 +420,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, process.cwd(), () => Effect.succeed( makeCodexProbeSnapshot({ account: { @@ -430,7 +440,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("returns unavailable when codex is missing", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, process.cwd(), () => Effect.fail( new CodexErrors.CodexAppServerSpawnError({ command: "codex app-server", @@ -451,10 +461,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("closes the app-server probe scope when provider status times out", () => Effect.gen(function* () { const killCalls = yield* Ref.make(0); - const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( - Effect.provide(hangingScopedSpawnerLayer(killCalls)), - Effect.forkChild, - ); + const statusFiber = yield* checkCodexProviderStatus( + defaultCodexSettings, + process.cwd(), + ).pipe(Effect.provide(hangingScopedSpawnerLayer(killCalls)), Effect.forkChild); yield* Effect.yieldNow; yield* TestClock.adjust("11 seconds"); @@ -1421,7 +1431,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("skips codex probes entirely when the provider is disabled", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( + const status = yield* checkCodexProviderStatus(disabledCodexSettings, process.cwd()).pipe( Effect.provide(failingSpawnerLayer("spawn codex ENOENT")), ); assert.strictEqual(status.enabled, false); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0f2a8f790bf..8de217f5cd9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -5,6 +5,8 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -40,6 +42,10 @@ import { OrchestrationReplayEventsError, FilesystemBrowseError, EnvironmentAuthorizationError, + CodexSettings, + ServerProviderSkillsListError, + type ServerProviderSkillsListResult, + type ProviderInstanceId, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -51,6 +57,7 @@ import { import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { ServerConfig } from "./config.ts"; @@ -65,6 +72,13 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import { deriveProviderInstanceConfigMap } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; +import { listCodexProviderSkills } from "./provider/Layers/CodexProvider.ts"; +import { + materializeCodexShadowHome, + resolveCodexHomeLayout, +} from "./provider/Drivers/CodexHomeLayout.ts"; +import { mergeProviderInstanceEnvironment } from "./provider/ProviderInstanceEnvironment.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; @@ -102,6 +116,7 @@ import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -139,6 +154,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], + [WS_METHODS.serverListProviderSkills, AuthOrchestrationReadScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], @@ -243,6 +259,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const providerRegistry = yield* ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const startup = yield* ServerRuntimeStartup; @@ -267,6 +286,82 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const relayClient = yield* RelayClient.RelayClient; + const listProviderSkills = Effect.fn("ws.listProviderSkills")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }): Effect.fn.Return { + const providers = yield* providerRegistry.getProviders; + const snapshot = providers.find((provider) => provider.instanceId === input.instanceId); + if (!snapshot) { + return yield* new ServerProviderSkillsListError({ + message: `Provider instance '${input.instanceId}' was not found.`, + }); + } + if (snapshot.driver !== "codex") { + return { skills: snapshot.skills }; + } + + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: "Failed to read provider settings.", + cause, + }), + ), + ); + const instanceConfig = deriveProviderInstanceConfigMap(settings)[input.instanceId]; + if (!instanceConfig || instanceConfig.driver !== "codex") { + return yield* new ServerProviderSkillsListError({ + message: `Codex provider instance '${input.instanceId}' is not configured.`, + }); + } + + const decodedConfig = yield* decodeCodexSettings(instanceConfig.config ?? {}).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Failed to decode Codex provider settings for '${input.instanceId}'.`, + cause, + }), + ), + ); + const effectiveConfig = { + ...decodedConfig, + enabled: instanceConfig.enabled ?? decodedConfig.enabled, + }; + const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( + Effect.provideService(Path.Path, path), + ); + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Failed to prepare Codex home for '${input.instanceId}': ${cause.message}`, + cause, + }), + ), + ); + const skills = yield* listCodexProviderSkills({ + binaryPath: effectiveConfig.binaryPath, + ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), + cwd: input.cwd, + environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), + }).pipe( + Effect.scoped, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Failed to list Codex skills for '${input.cwd}'.`, + cause, + }), + ), + ); + return { skills }; + }); const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -1000,6 +1095,10 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverListProviderSkills]: (input) => + observeRpcEffect(WS_METHODS.serverListProviderSkills, listProviderSkills(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8d89ccdd396..12de8644679 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -53,6 +53,7 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; +import { useProviderWorkspaceSkills } from "../../lib/providerWorkspaceSkillsState"; import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, @@ -890,11 +891,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; + const isSkillTrigger = composerTriggerKind === "skill"; const workspaceEntries = useComposerPathSearch({ environmentId, cwd: isPathTrigger ? gitCwd : null, query: isPathTrigger ? pathTriggerQuery : null, }); + const providerWorkspaceSkills = useProviderWorkspaceSkills({ + environmentId, + instanceId: selectedProviderStatus?.instanceId ?? null, + cwd: gitCwd, + enabled: isSkillTrigger, + fallbackSkills: selectedProviderStatus?.skills ?? [], + }); const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; @@ -950,7 +959,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( + return searchProviderSkills(providerWorkspaceSkills.skills, composerTrigger.query).map( (skill) => ({ id: `skill:${selectedProvider}:${skill.name}`, type: "skill" as const, @@ -965,7 +974,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerTrigger, + providerWorkspaceSkills.skills, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1030,7 +1045,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = - composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; + (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + (composerTriggerKind === "skill" && providerWorkspaceSkills.isPending); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; @@ -2307,7 +2323,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={providerWorkspaceSkills.skills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/apps/web/src/environments/runtime/connection.test.ts b/apps/web/src/environments/runtime/connection.test.ts index 392db299339..550b7a31f38 100644 --- a/apps/web/src/environments/runtime/connection.test.ts +++ b/apps/web/src/environments/runtime/connection.test.ts @@ -31,6 +31,7 @@ function createTestClient(config?: { readonly emitInitialSnapshot?: boolean }) { }), subscribeAuthAccess: () => () => undefined, refreshProviders: vi.fn(async () => undefined), + listProviderSkills: vi.fn(async () => ({ skills: [] })), upsertKeybinding: vi.fn(async () => undefined), getSettings: vi.fn(async () => undefined), updateSettings: vi.fn(async () => undefined), diff --git a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts index e7c15ec6b32..0220956080a 100644 --- a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts +++ b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts @@ -173,6 +173,7 @@ function createClient() { subscribeLifecycle: vi.fn(() => () => undefined), subscribeAuthAccess: vi.fn(() => () => undefined), refreshProviders: vi.fn(async () => undefined), + listProviderSkills: vi.fn(async () => ({ skills: [] })), upsertKeybinding: vi.fn(async () => undefined), getSettings: vi.fn(async () => undefined), updateSettings: vi.fn(async () => undefined), diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 675a4868032..cde158c43e5 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -141,6 +141,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { server: { getConfig: vi.fn(), refreshProviders: vi.fn(), + listProviderSkills: vi.fn(), discoverSourceControl: vi.fn(), updateProvider: vi.fn(), upsertKeybinding: vi.fn(), diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts new file mode 100644 index 00000000000..f8c85b48755 --- /dev/null +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -0,0 +1,135 @@ +import type { EnvironmentId, ProviderInstanceId, ServerProviderSkill } from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { + readEnvironmentConnection, + subscribeEnvironmentConnections, + subscribeProviderInvalidations, +} from "../environments/runtime"; + +export interface ProviderWorkspaceSkillsTarget { + readonly environmentId: EnvironmentId | null; + readonly instanceId: ProviderInstanceId | null; + readonly cwd: string | null; + readonly enabled: boolean; + readonly fallbackSkills: ReadonlyArray; +} + +export interface ProviderWorkspaceSkillsState { + readonly skills: ReadonlyArray; + readonly isPending: boolean; + readonly error: string | null; +} + +const cache = new Map>(); + +function targetKey(target: Omit): string | null { + if ( + !target.enabled || + target.environmentId === null || + target.instanceId === null || + target.cwd === null + ) { + return null; + } + return `${target.environmentId}:${target.instanceId}:${target.cwd}`; +} + +export function invalidateProviderWorkspaceSkills(): void { + cache.clear(); +} + +subscribeProviderInvalidations(invalidateProviderWorkspaceSkills); + +export function useProviderWorkspaceSkills( + target: ProviderWorkspaceSkillsTarget, +): ProviderWorkspaceSkillsState { + const stableTarget = useMemo( + () => ({ + environmentId: target.environmentId, + instanceId: target.instanceId, + cwd: target.cwd, + enabled: target.enabled, + }), + [target.cwd, target.enabled, target.environmentId, target.instanceId], + ); + const key = targetKey(stableTarget); + const [connectionVersion, setConnectionVersion] = useState(0); + const [state, setState] = useState(() => ({ + skills: target.fallbackSkills, + isPending: false, + error: null, + })); + + useEffect( + () => subscribeEnvironmentConnections(() => setConnectionVersion((version) => version + 1)), + [], + ); + + useEffect(() => { + if ( + key === null || + stableTarget.environmentId === null || + stableTarget.instanceId === null || + stableTarget.cwd === null + ) { + setState({ skills: target.fallbackSkills, isPending: false, error: null }); + return; + } + + const cached = cache.get(key); + if (cached) { + setState({ skills: cached, isPending: false, error: null }); + return; + } + + const connection = readEnvironmentConnection(stableTarget.environmentId); + if (!connection) { + setState({ + skills: target.fallbackSkills, + isPending: false, + error: "Remote connection is not ready.", + }); + return; + } + + let cancelled = false; + setState((current) => ({ + skills: current.skills.length > 0 ? current.skills : target.fallbackSkills, + isPending: true, + error: null, + })); + void connection.client.server + .listProviderSkills({ + instanceId: stableTarget.instanceId, + cwd: stableTarget.cwd, + }) + .then((result) => { + if (cancelled) return; + cache.set(key, result.skills); + setState({ skills: result.skills, isPending: false, error: null }); + }) + .catch((error: unknown) => { + if (cancelled) return; + setState({ + skills: target.fallbackSkills, + isPending: false, + error: error instanceof Error ? error.message : "Failed to list provider skills.", + }); + }); + + return () => { + cancelled = true; + }; + }, [ + connectionVersion, + key, + stableTarget.cwd, + stableTarget.enabled, + stableTarget.environmentId, + stableTarget.instanceId, + target.fallbackSkills, + ]); + + return state; +} diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index c5ee3f277ca..7b68a7b9ed1 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -125,6 +125,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { rpcClient ? rpcClient.server.refreshProviders() : Promise.reject(unavailableLocalBackendError()), + listProviderSkills: (input) => + rpcClient + ? rpcClient.server.listProviderSkills(input) + : Promise.reject(unavailableLocalBackendError()), updateProvider: (input) => rpcClient ? rpcClient.server.updateProvider(input) diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index c1c683616b2..cdadb13c8f1 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -130,6 +130,7 @@ export interface WsRpcClient { readonly refreshProviders: ( input?: RpcInput, ) => ReturnType>; + readonly listProviderSkills: RpcUnaryMethod; readonly discoverSourceControl: RpcUnaryNoArgMethod< typeof WS_METHODS.serverDiscoverSourceControl >; @@ -290,6 +291,8 @@ export function createWsRpcClient( getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), refreshProviders: (input) => transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), + listProviderSkills: (input) => + transport.request((client) => client[WS_METHODS.serverListProviderSkills](input)), discoverSourceControl: () => transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), updateProvider: (input) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1d8656ddf4f..c152e88098b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -32,6 +32,8 @@ import type { ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, ServerProcessResourceHistoryResult, + ServerProviderSkillsListInput, + ServerProviderSkillsListResult, ServerProviderUpdateInput, ServerProviderUpdatedPayload, ServerRemoveKeybindingResult, @@ -508,6 +510,9 @@ export interface LocalApi { refreshProviders: (input?: { readonly instanceId?: ProviderInstanceId; }) => Promise; + listProviderSkills: ( + input: ServerProviderSkillsListInput, + ) => Promise; updateProvider: (input: ServerProviderUpdateInput) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; removeKeybinding: (input: ServerRemoveKeybindingInput) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5a145f3f657..8508b97f7ee 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -93,6 +93,9 @@ import { ServerLifecycleStreamEvent, ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, + ServerProviderSkillsListError, + ServerProviderSkillsListInput, + ServerProviderSkillsListResult, ServerProviderUpdatedPayload, ServerTraceDiagnosticsResult, ServerProcessDiagnosticsResult, @@ -160,6 +163,7 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", + serverListProviderSkills: "server.listProviderSkills", serverUpdateProvider: "server.updateProvider", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", @@ -221,6 +225,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: EnvironmentAuthorizationError, }); +export const WsServerListProviderSkillsRpc = Rpc.make(WS_METHODS.serverListProviderSkills, { + payload: ServerProviderSkillsListInput, + success: ServerProviderSkillsListResult, + error: Schema.Union([ServerProviderSkillsListError, EnvironmentAuthorizationError]), +}); + export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, @@ -548,6 +558,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, + WsServerListProviderSkillsRpc, WsServerUpdateProviderRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 1aa280ad63b..bfc69c09917 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -91,6 +91,25 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderSkillsListInput = Schema.Struct({ + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, +}); +export type ServerProviderSkillsListInput = typeof ServerProviderSkillsListInput.Type; + +export const ServerProviderSkillsListResult = Schema.Struct({ + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderSkillsListResult = typeof ServerProviderSkillsListResult.Type; + +export class ServerProviderSkillsListError extends Schema.TaggedErrorClass()( + "ServerProviderSkillsListError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + /** * Availability of a configured provider instance from the runtime's POV. * From ace7991c39a14bb08603f01d8fe4547148c74d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 12:15:23 +0100 Subject: [PATCH 02/11] Address CodeRabbit review comments - Clarify bogus skill as a durable discovery test fixture - Stabilize composer fallback skill array identity --- .agents/skills/bogus/SKILL.md | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agents/skills/bogus/SKILL.md b/.agents/skills/bogus/SKILL.md index 506636b7b26..22a9efed62e 100644 --- a/.agents/skills/bogus/SKILL.md +++ b/.agents/skills/bogus/SKILL.md @@ -1,6 +1,6 @@ --- name: bogus -description: A useless skill to test only skill discovery. Can be committed, will remove manually before merging to main. +description: A test fixture skill used to validate reproducible skill discovery behavior. --- Nothing. diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 12de8644679..02122e6020d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -756,6 +756,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); + const selectedProviderFallbackSkills = useMemo( + () => selectedProviderStatus?.skills ?? [], + [selectedProviderStatus], + ); const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -902,7 +906,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) instanceId: selectedProviderStatus?.instanceId ?? null, cwd: gitCwd, enabled: isSkillTrigger, - fallbackSkills: selectedProviderStatus?.skills ?? [], + fallbackSkills: selectedProviderFallbackSkills, }); const composerMenuItems = useMemo(() => { From 5bc3d6e4c7ce55f5844d55806ac793fd4a4e992d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 12:24:15 +0100 Subject: [PATCH 03/11] Fix disabled Codex skill listing - Skip Codex skill spawning for disabled instances - Move bogus skill fixture out of workspace discovery --- .../server/integration/fixtures}/skills/bogus/SKILL.md | 0 apps/server/src/ws.ts | 3 +++ 2 files changed, 3 insertions(+) rename {.agents => apps/server/integration/fixtures}/skills/bogus/SKILL.md (100%) diff --git a/.agents/skills/bogus/SKILL.md b/apps/server/integration/fixtures/skills/bogus/SKILL.md similarity index 100% rename from .agents/skills/bogus/SKILL.md rename to apps/server/integration/fixtures/skills/bogus/SKILL.md diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8de217f5cd9..46919c811ad 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -330,6 +330,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ...decodedConfig, enabled: instanceConfig.enabled ?? decodedConfig.enabled, }; + if (!effectiveConfig.enabled) { + return { skills: snapshot.skills }; + } const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( Effect.provideService(Path.Path, path), ); From c34dff39f1732c00af358e965834bdfffc94531e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 13:37:32 +0100 Subject: [PATCH 04/11] Fix Codex skill listing refresh and validation - Refresh workspace skill cache on provider and connection changes - Validate Codex skill cwd before spawning the app server - Cover server.listProviderSkills RPC branches --- .../src/provider/Layers/CodexProvider.ts | 6 +- apps/server/src/server.test.ts | 187 ++++++++++++++++++ apps/server/src/ws.ts | 31 ++- .../src/lib/providerWorkspaceSkillsState.ts | 78 ++++++-- 4 files changed, 283 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index e01db62f7a0..a9bd16d7780 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -271,16 +271,18 @@ export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(func readonly binaryPath: string; readonly homePath?: string; readonly cwd: string; - readonly environment?: NodeJS.ProcessEnv; + readonly environment: NodeJS.ProcessEnv; }) { const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + // The app-server command layer is scoped; callers must run this effect with + // `Effect.scoped` so the spawned process finalizer is released. const clientContext = yield* Layer.build( CodexClient.layerCommand({ command: input.binaryPath, args: ["app-server"], cwd: input.cwd, env: { - ...(input.environment ?? process.env), + ...input.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }, }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0bf2f6589f0..605ea4dc152 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -24,6 +24,8 @@ import { ProviderDriverKind, ProviderInstanceId, ResolvedKeybindingRule, + type ServerProvider, + type ServerProviderSkill, ThreadId, WS_METHODS, WsRpcGroup, @@ -1115,6 +1117,24 @@ const responseJsonEffect = (response: HttpClientResponse.HttpClientResponse) const responseOk = (response: HttpClientResponse.HttpClientResponse) => response.status >= 200 && response.status < 300; +const makeServerProviderSnapshot = ( + input: Partial & { + readonly instanceId: ProviderInstanceId; + readonly driver: ProviderDriverKind; + }, +): ServerProvider => ({ + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...input, +}); + const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => Effect.gen(function* () { const { response, cookie } = yield* bootstrapBrowserSession(credential); @@ -4417,6 +4437,173 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc server.listProviderSkills errors for missing provider", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId: ProviderInstanceId.make("codex"), + cwd: process.cwd(), + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "ServerProviderSkillsListError"); + assert.equal(result.failure.message, "Provider instance 'codex' was not found."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc server.listProviderSkills returns non-Codex snapshot skills", + () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const skill: ServerProviderSkill = { + name: "plan", + path: "/providers/claudeAgent/skills/plan/SKILL.md", + enabled: true, + }; + const providers = [ + makeServerProviderSnapshot({ + instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + skills: [skill], + }), + ]; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed(providers), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId, + cwd: "/definitely/not/a/real/workspace/path", + }), + ), + ); + + assert.deepEqual(response.skills, [skill]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc server.listProviderSkills returns disabled Codex snapshot skills", + () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex"); + const driver = ProviderDriverKind.make("codex"); + const skill: ServerProviderSkill = { + name: "fallback", + path: "/providers/codex/skills/fallback/SKILL.md", + enabled: true, + }; + const providers = [ + makeServerProviderSnapshot({ + instanceId, + driver, + skills: [skill], + }), + ]; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed(providers), + }, + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver, + enabled: false, + config: {}, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId, + cwd: "/definitely/not/a/real/workspace/path", + }), + ), + ); + + assert.deepEqual(response.skills, [skill]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc server.listProviderSkills validates enabled Codex cwd", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex"); + const driver = ProviderDriverKind.make("codex"); + const providers = [ + makeServerProviderSnapshot({ + instanceId, + driver, + }), + ]; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed(providers), + }, + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver, + enabled: true, + config: {}, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId, + cwd: "/definitely/not/a/real/workspace/path", + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "ServerProviderSkillsListError"); + assertInclude( + result.failure.message, + "Invalid Codex skills cwd '/definitely/not/a/real/workspace/path'", + ); + assertInclude( + result.failure.message, + "Workspace root does not exist: /definitely/not/a/real/workspace/path", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "routes websocket rpc subscribeServerLifecycle replays snapshot and streams updates", () => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 46919c811ad..f3ebeb17ebb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -86,7 +86,10 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe import { TerminalManager } from "./terminal/Services/Manager.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; -import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; +import { + WorkspacePathOutsideRootError, + WorkspacePaths, +} from "./workspace/Services/WorkspacePaths.ts"; import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; import { GitWorkflowService } from "./git/GitWorkflowService.ts"; @@ -118,6 +121,16 @@ const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchComma const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); +function describeUnknownCause(cause: unknown): string { + if (cause instanceof Error) { + return cause.message; + } + if (typeof cause === "string") { + return cause; + } + return "Unknown error"; +} + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< @@ -262,6 +275,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const startup = yield* ServerRuntimeStartup; @@ -333,6 +347,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => if (!effectiveConfig.enabled) { return { skills: snapshot.skills }; } + const normalizedCwd = yield* workspacePaths.normalizeWorkspaceRoot(input.cwd).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Invalid Codex skills cwd '${input.cwd}': ${cause.message}`, + cause, + }), + ), + ); const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( Effect.provideService(Path.Path, path), ); @@ -342,7 +365,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Effect.mapError( (cause) => new ServerProviderSkillsListError({ - message: `Failed to prepare Codex home for '${input.instanceId}': ${cause.message}`, + message: `Failed to prepare Codex home for '${input.instanceId}': ${describeUnknownCause(cause)}`, cause, }), ), @@ -350,7 +373,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const skills = yield* listCodexProviderSkills({ binaryPath: effectiveConfig.binaryPath, ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), - cwd: input.cwd, + cwd: normalizedCwd, environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), }).pipe( Effect.scoped, @@ -358,7 +381,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Effect.mapError( (cause) => new ServerProviderSkillsListError({ - message: `Failed to list Codex skills for '${input.cwd}'.`, + message: `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${normalizedCwd}').`, cause, }), ), diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index f8c85b48755..9c8b9401c07 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -1,5 +1,5 @@ import type { EnvironmentId, ProviderInstanceId, ServerProviderSkill } from "@t3tools/contracts"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { readEnvironmentConnection, @@ -22,33 +22,79 @@ export interface ProviderWorkspaceSkillsState { } const cache = new Map>(); +const CACHE_MAX_ENTRIES = 100; + +const listeners = new Set<() => void>(); +let unsubscribeEnvironmentConnections: (() => void) | null = null; +let unsubscribeProviderInvalidations: (() => void) | null = null; + +function notifyListeners(): void { + for (const listener of listeners) { + listener(); + } +} + +function clearCacheAndNotify(): void { + invalidateProviderWorkspaceSkills(); + notifyListeners(); +} + +function setCachedSkills(key: string, skills: ReadonlyArray): void { + if (cache.has(key)) { + cache.delete(key); + } + cache.set(key, skills); + while (cache.size > CACHE_MAX_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey === undefined) break; + cache.delete(oldestKey); + } +} + +function subscribeWorkspaceSkillChanges(listener: () => void): () => void { + listeners.add(listener); + if (listeners.size === 1) { + unsubscribeEnvironmentConnections = subscribeEnvironmentConnections(clearCacheAndNotify); + unsubscribeProviderInvalidations = subscribeProviderInvalidations(clearCacheAndNotify); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + unsubscribeEnvironmentConnections?.(); + unsubscribeEnvironmentConnections = null; + unsubscribeProviderInvalidations?.(); + unsubscribeProviderInvalidations = null; + invalidateProviderWorkspaceSkills(); + } + }; +} function targetKey(target: Omit): string | null { if ( !target.enabled || target.environmentId === null || target.instanceId === null || - target.cwd === null + target.cwd === null || + target.cwd.trim().length === 0 ) { return null; } - return `${target.environmentId}:${target.instanceId}:${target.cwd}`; + return `${target.environmentId}:${target.instanceId}:${target.cwd.trim()}`; } export function invalidateProviderWorkspaceSkills(): void { cache.clear(); } -subscribeProviderInvalidations(invalidateProviderWorkspaceSkills); - export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { + const fallbackSkillsRef = useRef(target.fallbackSkills); const stableTarget = useMemo( () => ({ environmentId: target.environmentId, instanceId: target.instanceId, - cwd: target.cwd, + cwd: target.cwd?.trim() || null, enabled: target.enabled, }), [target.cwd, target.enabled, target.environmentId, target.instanceId], @@ -61,8 +107,15 @@ export function useProviderWorkspaceSkills( error: null, })); + useEffect(() => { + fallbackSkillsRef.current = target.fallbackSkills; + if (key === null) { + setState({ skills: target.fallbackSkills, isPending: false, error: null }); + } + }, [key, target.fallbackSkills]); + useEffect( - () => subscribeEnvironmentConnections(() => setConnectionVersion((version) => version + 1)), + () => subscribeWorkspaceSkillChanges(() => setConnectionVersion((version) => version + 1)), [], ); @@ -73,7 +126,7 @@ export function useProviderWorkspaceSkills( stableTarget.instanceId === null || stableTarget.cwd === null ) { - setState({ skills: target.fallbackSkills, isPending: false, error: null }); + setState({ skills: fallbackSkillsRef.current, isPending: false, error: null }); return; } @@ -86,7 +139,7 @@ export function useProviderWorkspaceSkills( const connection = readEnvironmentConnection(stableTarget.environmentId); if (!connection) { setState({ - skills: target.fallbackSkills, + skills: fallbackSkillsRef.current, isPending: false, error: "Remote connection is not ready.", }); @@ -95,7 +148,7 @@ export function useProviderWorkspaceSkills( let cancelled = false; setState((current) => ({ - skills: current.skills.length > 0 ? current.skills : target.fallbackSkills, + skills: current.skills.length > 0 ? current.skills : fallbackSkillsRef.current, isPending: true, error: null, })); @@ -106,13 +159,13 @@ export function useProviderWorkspaceSkills( }) .then((result) => { if (cancelled) return; - cache.set(key, result.skills); + setCachedSkills(key, result.skills); setState({ skills: result.skills, isPending: false, error: null }); }) .catch((error: unknown) => { if (cancelled) return; setState({ - skills: target.fallbackSkills, + skills: fallbackSkillsRef.current, isPending: false, error: error instanceof Error ? error.message : "Failed to list provider skills.", }); @@ -128,7 +181,6 @@ export function useProviderWorkspaceSkills( stableTarget.enabled, stableTarget.environmentId, stableTarget.instanceId, - target.fallbackSkills, ]); return state; From 1a1547fbe6ab0d1c65344aa2c6a8f7fee8cc5095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 13:47:26 +0100 Subject: [PATCH 05/11] Fix workspace skill refresh state - Track the active workspace key in provider skill state - Reset pending skills when switching workspace targets --- .../src/lib/providerWorkspaceSkillsState.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index 9c8b9401c07..ff1837faa9a 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -21,6 +21,10 @@ export interface ProviderWorkspaceSkillsState { readonly error: string | null; } +interface InternalProviderWorkspaceSkillsState extends ProviderWorkspaceSkillsState { + readonly key: string | null; +} + const cache = new Map>(); const CACHE_MAX_ENTRIES = 100; @@ -101,7 +105,8 @@ export function useProviderWorkspaceSkills( ); const key = targetKey(stableTarget); const [connectionVersion, setConnectionVersion] = useState(0); - const [state, setState] = useState(() => ({ + const [state, setState] = useState(() => ({ + key, skills: target.fallbackSkills, isPending: false, error: null, @@ -110,7 +115,7 @@ export function useProviderWorkspaceSkills( useEffect(() => { fallbackSkillsRef.current = target.fallbackSkills; if (key === null) { - setState({ skills: target.fallbackSkills, isPending: false, error: null }); + setState({ key: null, skills: target.fallbackSkills, isPending: false, error: null }); } }, [key, target.fallbackSkills]); @@ -126,19 +131,20 @@ export function useProviderWorkspaceSkills( stableTarget.instanceId === null || stableTarget.cwd === null ) { - setState({ skills: fallbackSkillsRef.current, isPending: false, error: null }); + setState({ key, skills: fallbackSkillsRef.current, isPending: false, error: null }); return; } const cached = cache.get(key); if (cached) { - setState({ skills: cached, isPending: false, error: null }); + setState({ key, skills: cached, isPending: false, error: null }); return; } const connection = readEnvironmentConnection(stableTarget.environmentId); if (!connection) { setState({ + key, skills: fallbackSkillsRef.current, isPending: false, error: "Remote connection is not ready.", @@ -148,7 +154,11 @@ export function useProviderWorkspaceSkills( let cancelled = false; setState((current) => ({ - skills: current.skills.length > 0 ? current.skills : fallbackSkillsRef.current, + key, + skills: + current.key === key && current.skills.length > 0 + ? current.skills + : fallbackSkillsRef.current, isPending: true, error: null, })); @@ -160,11 +170,12 @@ export function useProviderWorkspaceSkills( .then((result) => { if (cancelled) return; setCachedSkills(key, result.skills); - setState({ skills: result.skills, isPending: false, error: null }); + setState({ key, skills: result.skills, isPending: false, error: null }); }) .catch((error: unknown) => { if (cancelled) return; setState({ + key, skills: fallbackSkillsRef.current, isPending: false, error: error instanceof Error ? error.message : "Failed to list provider skills.", @@ -183,5 +194,9 @@ export function useProviderWorkspaceSkills( stableTarget.instanceId, ]); - return state; + return { + skills: state.skills, + isPending: state.isPending, + error: state.error, + }; } From b38045bb8496608eef4e8e37d39401c6bf270558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 14:47:51 +0100 Subject: [PATCH 06/11] Remove test skill file --- apps/server/integration/fixtures/skills/bogus/SKILL.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 apps/server/integration/fixtures/skills/bogus/SKILL.md diff --git a/apps/server/integration/fixtures/skills/bogus/SKILL.md b/apps/server/integration/fixtures/skills/bogus/SKILL.md deleted file mode 100644 index 22a9efed62e..00000000000 --- a/apps/server/integration/fixtures/skills/bogus/SKILL.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: bogus -description: A test fixture skill used to validate reproducible skill discovery behavior. ---- - -Nothing. From 6acc26490ae76d46741bd639d2453566f37df1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 16:10:38 +0100 Subject: [PATCH 07/11] Fix workspace-scoped Codex skill loading - Keep pending skill lookups scoped to the active workspace key - Surface Codex skill-list timeout errors in the composer - Add regression coverage for stale pending skills --- apps/server/src/ws.ts | 14 ++++++- apps/web/src/components/chat/ChatComposer.tsx | 7 ++-- .../lib/providerWorkspaceSkillsState.test.ts | 42 +++++++++++++++++++ .../src/lib/providerWorkspaceSkillsState.ts | 22 +++++++--- 4 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/lib/providerWorkspaceSkillsState.test.ts diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f3ebeb17ebb..d9d0e4d00e2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -131,6 +131,13 @@ function describeUnknownCause(cause: unknown): string { return "Unknown error"; } +function describeCodexSkillListFailure(cause: unknown, input: { instanceId: string; cwd: string }) { + if (Cause.isTimeoutError(cause)) { + return `Timed out listing Codex skills after ${Duration.toSeconds(CODEX_SKILL_LIST_TIMEOUT)}s (provider: '${input.instanceId}', cwd: '${input.cwd}').`; + } + return `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${input.cwd}').`; +} + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< @@ -156,6 +163,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< } const PROVIDER_STATUS_DEBOUNCE_MS = 200; +const CODEX_SKILL_LIST_TIMEOUT = Duration.seconds(15); const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], @@ -377,11 +385,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), }).pipe( Effect.scoped, + Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.mapError( (cause) => new ServerProviderSkillsListError({ - message: `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${normalizedCwd}').`, + message: describeCodexSkillListFailure(cause, { + instanceId: input.instanceId, + cwd: normalizedCwd, + }), cause, }), ), diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 02122e6020d..3d83c7f2ae3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -895,7 +895,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; - const isSkillTrigger = composerTriggerKind === "skill"; const workspaceEntries = useComposerPathSearch({ environmentId, cwd: isPathTrigger ? gitCwd : null, @@ -905,7 +904,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, instanceId: selectedProviderStatus?.instanceId ?? null, cwd: gitCwd, - enabled: isSkillTrigger, + enabled: true, fallbackSkills: selectedProviderFallbackSkills, }); @@ -1053,12 +1052,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (composerTriggerKind === "skill" && providerWorkspaceSkills.isPending); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { - return "No skills found. Try / to browse provider commands."; + return providerWorkspaceSkills.error ?? "No skills found. Try / to browse provider commands."; } return composerTriggerKind === "path" ? "No matching files or folders." : "No matching command."; - }, [composerTriggerKind]); + }, [composerTriggerKind, providerWorkspaceSkills.error]); // ------------------------------------------------------------------ // Provider traits UI diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts new file mode 100644 index 00000000000..b14d38ed9c8 --- /dev/null +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -0,0 +1,42 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../environments/runtime", () => ({ + readEnvironmentConnection: vi.fn(() => null), + subscribeEnvironmentConnections: vi.fn(() => () => undefined), + subscribeProviderInvalidations: vi.fn(() => () => undefined), +})); + +import { resolvePendingProviderWorkspaceSkills } from "./providerWorkspaceSkillsState"; + +function skill(name: string): ServerProviderSkill { + return { + name, + path: `/skills/${name}/SKILL.md`, + enabled: true, + }; +} + +describe("resolvePendingProviderWorkspaceSkills", () => { + it("preserves current skills while refreshing the same workspace key", () => { + const currentSkills = [skill("repo-local")]; + + expect( + resolvePendingProviderWorkspaceSkills({ + currentKey: "environment:codex:/repo", + nextKey: "environment:codex:/repo", + currentSkills, + }), + ).toBe(currentSkills); + }); + + it("does not expose previous or snapshot skills while a different workspace key is pending", () => { + const pendingSkills = resolvePendingProviderWorkspaceSkills({ + currentKey: "environment:codex:/old-repo", + nextKey: "environment:codex:/new-repo", + currentSkills: [skill("old-repo-skill"), skill("snapshot-skill")], + }); + + expect(pendingSkills).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index ff1837faa9a..c0fd2ee02d8 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -27,6 +27,7 @@ interface InternalProviderWorkspaceSkillsState extends ProviderWorkspaceSkillsSt const cache = new Map>(); const CACHE_MAX_ENTRIES = 100; +const EMPTY_SKILLS: ReadonlyArray = []; const listeners = new Set<() => void>(); let unsubscribeEnvironmentConnections: (() => void) | null = null; @@ -90,6 +91,16 @@ export function invalidateProviderWorkspaceSkills(): void { cache.clear(); } +export function resolvePendingProviderWorkspaceSkills(input: { + readonly currentKey: string | null; + readonly nextKey: string; + readonly currentSkills: ReadonlyArray; +}): ReadonlyArray { + return input.currentKey === input.nextKey && input.currentSkills.length > 0 + ? input.currentSkills + : EMPTY_SKILLS; +} + export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { @@ -155,10 +166,11 @@ export function useProviderWorkspaceSkills( let cancelled = false; setState((current) => ({ key, - skills: - current.key === key && current.skills.length > 0 - ? current.skills - : fallbackSkillsRef.current, + skills: resolvePendingProviderWorkspaceSkills({ + currentKey: current.key, + nextKey: key, + currentSkills: current.skills, + }), isPending: true, error: null, })); @@ -176,7 +188,7 @@ export function useProviderWorkspaceSkills( if (cancelled) return; setState({ key, - skills: fallbackSkillsRef.current, + skills: EMPTY_SKILLS, isPending: false, error: error instanceof Error ? error.message : "Failed to list provider skills.", }); From 00cebadb8bf7e91f9300d7ef0c1367dcfd019a1b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 18 Jun 2026 23:12:27 -0700 Subject: [PATCH 08/11] Bound workspace skill discovery requests Co-authored-by: codex --- .../src/provider/Layers/CodexProvider.ts | 28 ++- .../src/provider/ProviderSkillsLister.test.ts | 67 ++++++ .../src/provider/ProviderSkillsLister.ts | 198 ++++++++++++++++++ apps/server/src/ws.ts | 153 ++------------ apps/web/src/components/chat/ChatComposer.tsx | 6 +- .../src/lib/providerWorkspaceSkillsState.ts | 121 ++--------- packages/client-runtime/src/state/server.ts | 10 +- 7 files changed, 326 insertions(+), 257 deletions(-) create mode 100644 apps/server/src/provider/ProviderSkillsLister.test.ts create mode 100644 apps/server/src/provider/ProviderSkillsLister.ts diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 3650b59c44e..8343a97e1b9 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -286,15 +286,25 @@ export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(func env: environment, extendEnv: true, }); - const child = yield* spawner.spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: input.cwd, - env: environment, - extendEnv: true, - forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, - shell: spawnCommand.shell, - }), - ); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), diff --git a/apps/server/src/provider/ProviderSkillsLister.test.ts b/apps/server/src/provider/ProviderSkillsLister.test.ts new file mode 100644 index 00000000000..38233740ea4 --- /dev/null +++ b/apps/server/src/provider/ProviderSkillsLister.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; + +import { makeBoundedRequestCache } from "./ProviderSkillsLister.ts"; + +describe("makeBoundedRequestCache", () => { + it.effect("coalesces concurrent requests for the same key", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const release = yield* Deferred.make(); + const requests = yield* makeBoundedRequestCache({ + capacity: 8, + concurrency: 2, + timeToLive: "1 second", + lookup: (key: string) => + Ref.update(calls, (count) => count + 1).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(`value:${key}`), + ), + }); + + const first = yield* Effect.forkChild(requests.get("repo")); + const second = yield* Effect.forkChild(requests.get("repo")); + yield* Effect.yieldNow; + expect(yield* Ref.get(calls)).toBe(1); + + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(first)).toBe("value:repo"); + expect(yield* Fiber.join(second)).toBe("value:repo"); + expect(yield* Ref.get(calls)).toBe(1); + }), + ); + + it.effect("bounds concurrent lookups across different keys", () => + Effect.gen(function* () { + const active = yield* Ref.make(0); + const maxActive = yield* Ref.make(0); + const release = yield* Deferred.make(); + const requests = yield* makeBoundedRequestCache({ + capacity: 8, + concurrency: 2, + timeToLive: "1 second", + lookup: (key: string) => + Effect.acquireUseRelease( + Ref.updateAndGet(active, (count) => count + 1).pipe( + Effect.tap((count) => Ref.update(maxActive, (maximum) => Math.max(maximum, count))), + ), + () => Deferred.await(release).pipe(Effect.as(key)), + () => Ref.update(active, (count) => count - 1), + ), + }); + + const fibers = yield* Effect.forEach(["one", "two", "three"], requests.get, { + concurrency: "unbounded", + discard: false, + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(yield* Ref.get(maxActive)).toBe(2); + + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(fibers)).toEqual(["one", "two", "three"]); + }), + ); +}); diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts new file mode 100644 index 00000000000..6079456e610 --- /dev/null +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -0,0 +1,198 @@ +import { + CodexSettings, + ServerProviderSkillsListError, + type ProviderInstanceId, + type ServerProviderSkillsListResult, +} from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { materializeCodexShadowHome, resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts"; +import { listCodexProviderSkills } from "./Layers/CodexProvider.ts"; +import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts"; +import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; + +const CODEX_SKILL_LIST_TIMEOUT = Duration.seconds(15); +const PROVIDER_SKILLS_CACHE_CAPACITY = 64; +const PROVIDER_SKILLS_CACHE_TTL = Duration.seconds(1); +const PROVIDER_SKILLS_MAX_CONCURRENCY = 4; +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); + +export interface ProviderSkillsListInput { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; +} + +export interface BoundedRequestCache { + readonly get: (key: Key) => Effect.Effect; +} + +export const makeBoundedRequestCache = Effect.fn("makeBoundedRequestCache")(function* < + Key, + A, + E, + R, +>(options: { + readonly capacity: number; + readonly concurrency: number; + readonly timeToLive: Duration.Input; + readonly lookup: (key: Key) => Effect.Effect; +}): Effect.fn.Return, never, R> { + const semaphore = yield* Semaphore.make(options.concurrency); + const cache = yield* Cache.make({ + capacity: options.capacity, + timeToLive: options.timeToLive, + lookup: (key: Key) => semaphore.withPermits(1)(options.lookup(key)), + }); + return { + get: (key) => Cache.get(cache, key), + }; +}); + +function describeUnknownCause(cause: unknown): string { + if (cause instanceof Error) return cause.message; + if (typeof cause === "string") return cause; + return "Unknown error"; +} + +function describeCodexSkillListFailure( + cause: unknown, + input: { readonly instanceId: string; readonly cwd: string }, +): string { + if (Cause.isTimeoutError(cause)) { + return `Timed out listing Codex skills after ${Duration.toSeconds(CODEX_SKILL_LIST_TIMEOUT)}s (provider: '${input.instanceId}', cwd: '${input.cwd}').`; + } + return `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${input.cwd}').`; +} + +function requestKey(input: ProviderSkillsListInput): string { + return JSON.stringify([input.instanceId, input.cwd]); +} + +function parseRequestKey(key: string): ProviderSkillsListInput { + const [instanceId, cwd] = JSON.parse(key) as [ProviderInstanceId, string]; + return { instanceId, cwd }; +} + +export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(function* () { + const providerRegistry = yield* ProviderRegistry; + const serverSettings = yield* ServerSettingsService; + const workspacePaths = yield* WorkspacePaths; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const listUncached = Effect.fn("ProviderSkillsLister.listUncached")(function* ( + input: ProviderSkillsListInput, + ): Effect.fn.Return { + const providers = yield* providerRegistry.getProviders; + const snapshot = providers.find((provider) => provider.instanceId === input.instanceId); + if (!snapshot) { + return yield* new ServerProviderSkillsListError({ + message: `Provider instance '${input.instanceId}' was not found.`, + }); + } + if (snapshot.driver !== "codex") { + return { skills: snapshot.skills }; + } + + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: "Failed to read provider settings.", + cause, + }), + ), + ); + const instanceConfig = deriveProviderInstanceConfigMap(settings)[input.instanceId]; + if (!instanceConfig || instanceConfig.driver !== "codex") { + return yield* new ServerProviderSkillsListError({ + message: `Codex provider instance '${input.instanceId}' is not configured.`, + }); + } + + const decodedConfig = yield* decodeCodexSettings(instanceConfig.config ?? {}).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Failed to decode Codex provider settings for '${input.instanceId}'.`, + cause, + }), + ), + ); + const effectiveConfig = { + ...decodedConfig, + enabled: instanceConfig.enabled ?? decodedConfig.enabled, + }; + if (!effectiveConfig.enabled) { + return { skills: snapshot.skills }; + } + + const normalizedCwd = yield* workspacePaths.normalizeWorkspaceRoot(input.cwd).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Invalid Codex skills cwd '${input.cwd}': ${cause.message}`, + cause, + }), + ), + ); + const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( + Effect.provideService(Path.Path, path), + ); + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Failed to prepare Codex home for '${input.instanceId}': ${describeUnknownCause(cause)}`, + cause, + }), + ), + ); + const skills = yield* listCodexProviderSkills({ + binaryPath: effectiveConfig.binaryPath, + ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), + cwd: normalizedCwd, + environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), + }).pipe( + Effect.scoped, + Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: describeCodexSkillListFailure(cause, { + instanceId: input.instanceId, + cwd: normalizedCwd, + }), + cause, + }), + ), + ); + return { skills }; + }); + + const requests = yield* makeBoundedRequestCache({ + capacity: PROVIDER_SKILLS_CACHE_CAPACITY, + concurrency: PROVIDER_SKILLS_MAX_CONCURRENCY, + timeToLive: PROVIDER_SKILLS_CACHE_TTL, + lookup: (key: string) => listUncached(parseRequestKey(key)), + }); + + return Effect.fn("ProviderSkillsLister.list")(function* (input: ProviderSkillsListInput) { + return yield* requests.get(requestKey(input)); + }); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index de181e565ae..f178799821b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -5,8 +5,6 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -46,10 +44,8 @@ import { FilesystemBrowseError, AssetAccessError, EnvironmentAuthorizationError, - CodexSettings, ServerProviderSkillsListError, type ServerProviderSkillsListResult, - type ProviderInstanceId, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -61,7 +57,6 @@ import { import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -import { ChildProcessSpawner } from "effect/unstable/process"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { ServerConfig } from "./config.ts"; @@ -76,13 +71,10 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; -import { deriveProviderInstanceConfigMap } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; -import { listCodexProviderSkills } from "./provider/Layers/CodexProvider.ts"; import { - materializeCodexShadowHome, - resolveCodexHomeLayout, -} from "./provider/Drivers/CodexHomeLayout.ts"; -import { mergeProviderInstanceEnvironment } from "./provider/ProviderInstanceEnvironment.ts"; + makeProviderSkillsLister, + type ProviderSkillsListInput, +} from "./provider/ProviderSkillsLister.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; @@ -94,10 +86,7 @@ import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; -import { - WorkspacePathOutsideRootError, - WorkspacePaths, -} from "./workspace/Services/WorkspacePaths.ts"; +import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; import { GitWorkflowService } from "./git/GitWorkflowService.ts"; @@ -127,25 +116,6 @@ import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); -const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); - -function describeUnknownCause(cause: unknown): string { - if (cause instanceof Error) { - return cause.message; - } - if (typeof cause === "string") { - return cause; - } - return "Unknown error"; -} - -function describeCodexSkillListFailure(cause: unknown, input: { instanceId: string; cwd: string }) { - if (Cause.isTimeoutError(cause)) { - return `Timed out listing Codex skills after ${Duration.toSeconds(CODEX_SKILL_LIST_TIMEOUT)}s (provider: '${input.instanceId}', cwd: '${input.cwd}').`; - } - return `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${input.cwd}').`; -} - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< @@ -171,7 +141,6 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< } const PROVIDER_STATUS_DEBOUNCE_MS = 200; -const CODEX_SKILL_LIST_TIMEOUT = Duration.seconds(15); const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], @@ -285,7 +254,12 @@ function toAuthAccessStreamEvent( } } -const makeWsRpcLayer = (currentSession: AuthenticatedSession) => +const makeWsRpcLayer = ( + currentSession: AuthenticatedSession, + listProviderSkills: ( + input: ProviderSkillsListInput, + ) => Effect.Effect, +) => WsRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; @@ -306,10 +280,6 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const providerRegistry = yield* ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig; - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspacePaths = yield* WorkspacePaths; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const startup = yield* ServerRuntimeStartup; @@ -334,98 +304,6 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const relayClient = yield* RelayClient.RelayClient; - const listProviderSkills = Effect.fn("ws.listProviderSkills")(function* (input: { - readonly instanceId: ProviderInstanceId; - readonly cwd: string; - }): Effect.fn.Return { - const providers = yield* providerRegistry.getProviders; - const snapshot = providers.find((provider) => provider.instanceId === input.instanceId); - if (!snapshot) { - return yield* new ServerProviderSkillsListError({ - message: `Provider instance '${input.instanceId}' was not found.`, - }); - } - if (snapshot.driver !== "codex") { - return { skills: snapshot.skills }; - } - - const settings = yield* serverSettings.getSettings.pipe( - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: "Failed to read provider settings.", - cause, - }), - ), - ); - const instanceConfig = deriveProviderInstanceConfigMap(settings)[input.instanceId]; - if (!instanceConfig || instanceConfig.driver !== "codex") { - return yield* new ServerProviderSkillsListError({ - message: `Codex provider instance '${input.instanceId}' is not configured.`, - }); - } - - const decodedConfig = yield* decodeCodexSettings(instanceConfig.config ?? {}).pipe( - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: `Failed to decode Codex provider settings for '${input.instanceId}'.`, - cause, - }), - ), - ); - const effectiveConfig = { - ...decodedConfig, - enabled: instanceConfig.enabled ?? decodedConfig.enabled, - }; - if (!effectiveConfig.enabled) { - return { skills: snapshot.skills }; - } - const normalizedCwd = yield* workspacePaths.normalizeWorkspaceRoot(input.cwd).pipe( - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: `Invalid Codex skills cwd '${input.cwd}': ${cause.message}`, - cause, - }), - ), - ); - const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( - Effect.provideService(Path.Path, path), - ); - yield* materializeCodexShadowHome(homeLayout).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: `Failed to prepare Codex home for '${input.instanceId}': ${describeUnknownCause(cause)}`, - cause, - }), - ), - ); - const skills = yield* listCodexProviderSkills({ - binaryPath: effectiveConfig.binaryPath, - ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), - cwd: normalizedCwd, - environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), - }).pipe( - Effect.scoped, - Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: describeCodexSkillListFailure(cause, { - instanceId: input.instanceId, - cwd: normalizedCwd, - }), - cause, - }), - ), - ); - return { skills }; - }); const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -1776,8 +1654,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ); export const websocketRpcRouteLayer = Layer.unwrap( - Effect.succeed( - HttpRouter.add( + Effect.gen(function* () { + const listProviderSkills = yield* makeProviderSkillsLister(); + return HttpRouter.add( "GET", "/ws", Effect.gen(function* () { @@ -1794,7 +1673,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session).pipe( + makeWsRpcLayer(session, listProviderSkills).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ProviderMaintenanceRunner.layer), @@ -1833,6 +1712,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( EnvironmentInternalError: HttpServerRespondable.toResponse, }), ), - ), - ), + ); + }), ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 82ebb6f1809..0f68a77e97a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -126,6 +126,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; +const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const runtimeModeConfig: Record< RuntimeMode, @@ -783,10 +784,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); - const selectedProviderFallbackSkills = useMemo( - () => selectedProviderStatus?.skills ?? [], - [selectedProviderStatus], - ); + const selectedProviderFallbackSkills = selectedProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS; const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index d35a3f9609a..dafa99ecf43 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -1,5 +1,5 @@ import type { EnvironmentId, ProviderInstanceId, ServerProviderSkill } from "@t3tools/contracts"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { serverEnvironment } from "../state/server"; import { useEnvironmentQuery } from "../state/query"; @@ -18,26 +18,8 @@ export interface ProviderWorkspaceSkillsState { readonly error: string | null; } -interface InternalProviderWorkspaceSkillsState extends ProviderWorkspaceSkillsState { - readonly key: string | null; -} - -const cache = new Map>(); -const CACHE_MAX_ENTRIES = 100; const EMPTY_SKILLS: ReadonlyArray = []; -function setCachedSkills(key: string, skills: ReadonlyArray): void { - if (cache.has(key)) { - cache.delete(key); - } - cache.set(key, skills); - while (cache.size > CACHE_MAX_ENTRIES) { - const oldestKey = cache.keys().next().value; - if (oldestKey === undefined) break; - cache.delete(oldestKey); - } -} - function targetKey(target: Omit): string | null { if ( !target.enabled || @@ -51,10 +33,6 @@ function targetKey(target: Omit return `${target.environmentId}:${target.instanceId}:${target.cwd.trim()}`; } -export function invalidateProviderWorkspaceSkills(): void { - cache.clear(); -} - export function resolvePendingProviderWorkspaceSkills(input: { readonly currentKey: string | null; readonly nextKey: string; @@ -68,7 +46,6 @@ export function resolvePendingProviderWorkspaceSkills(input: { export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { - const fallbackSkillsRef = useRef(target.fallbackSkills); const stableTarget = useMemo( () => ({ environmentId: target.environmentId, @@ -79,91 +56,31 @@ export function useProviderWorkspaceSkills( [target.cwd, target.enabled, target.environmentId, target.instanceId], ); const key = targetKey(stableTarget); - const skillsAtom = - key === null || - stableTarget.environmentId === null || - stableTarget.instanceId === null || - stableTarget.cwd === null - ? null - : serverEnvironment.listProviderSkills({ + const query = useEnvironmentQuery( + key !== null && stableTarget.environmentId !== null && stableTarget.instanceId !== null + ? serverEnvironment.providerSkills({ environmentId: stableTarget.environmentId, input: { instanceId: stableTarget.instanceId, - cwd: stableTarget.cwd, + cwd: stableTarget.cwd!, }, - }); - const skillQuery = useEnvironmentQuery(skillsAtom); - const [state, setState] = useState(() => ({ - key, - skills: target.fallbackSkills, - isPending: false, - error: null, - })); - - useEffect(() => { - fallbackSkillsRef.current = target.fallbackSkills; - if (key === null) { - setState({ key: null, skills: target.fallbackSkills, isPending: false, error: null }); - } - }, [key, target.fallbackSkills]); + }) + : null, + ); + const previousFallbackSkillsRef = useRef(target.fallbackSkills); useEffect(() => { - if ( - key === null || - stableTarget.environmentId === null || - stableTarget.instanceId === null || - stableTarget.cwd === null - ) { - setState({ key, skills: fallbackSkillsRef.current, isPending: false, error: null }); - return; - } - - const cached = cache.get(key); - if (cached && skillQuery.isPending) { - setState({ key, skills: cached, isPending: false, error: null }); - return; - } - - if (skillQuery.data) { - setCachedSkills(key, skillQuery.data.skills); - setState({ key, skills: skillQuery.data.skills, isPending: false, error: null }); - return; - } - - if (skillQuery.error) { - setState({ - key, - skills: EMPTY_SKILLS, - isPending: false, - error: skillQuery.error, - }); - return; - } - - setState((current) => ({ - key, - skills: resolvePendingProviderWorkspaceSkills({ - currentKey: current.key, - nextKey: key, - currentSkills: current.skills, - }), - isPending: skillQuery.isPending, - error: null, - })); - }, [ - key, - skillQuery.data, - skillQuery.error, - skillQuery.isPending, - stableTarget.cwd, - stableTarget.enabled, - stableTarget.environmentId, - stableTarget.instanceId, - ]); + if (previousFallbackSkillsRef.current === target.fallbackSkills) return; + previousFallbackSkillsRef.current = target.fallbackSkills; + if (key !== null) query.refresh(); + }, [key, query, target.fallbackSkills]); + if (key === null) { + return { skills: target.fallbackSkills, isPending: false, error: null }; + } return { - skills: state.skills, - isPending: state.isPending, - error: state.error, + skills: query.data?.skills ?? EMPTY_SKILLS, + isPending: query.isPending, + error: query.error, }; } diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 355568ebc06..f103d8ea346 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -133,6 +133,11 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, }), + providerSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:provider-skills", + tag: WS_METHODS.serverListProviderSkills, + staleTimeMs: 30_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", @@ -150,11 +155,6 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), - listProviderSkills: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:server:provider-skills", - tag: WS_METHODS.serverListProviderSkills, - staleTimeMs: 5_000, - }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, From 5c37b879d1885d52ff49783e6aeedb422d889804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 19 Jun 2026 18:00:51 +0100 Subject: [PATCH 09/11] Preserve workspace skills during refresh - Keep prior workspace skills visible while refreshes are pending - Add tests for loaded, pending, workspace-switch, and empty states --- .../lib/providerWorkspaceSkillsState.test.ts | 59 ++++++++++++++++++- .../src/lib/providerWorkspaceSkillsState.ts | 29 ++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts index d11475c4d62..26c30ae0dc1 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -1,7 +1,10 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolvePendingProviderWorkspaceSkills } from "./providerWorkspaceSkillsState"; +import { + resolvePendingProviderWorkspaceSkills, + resolveProviderWorkspaceSkills, +} from "./providerWorkspaceSkillsState"; function skill(name: string): ServerProviderSkill { return { @@ -34,3 +37,57 @@ describe("resolvePendingProviderWorkspaceSkills", () => { expect(pendingSkills).toEqual([]); }); }); + +describe("resolveProviderWorkspaceSkills", () => { + it("uses loaded skills as soon as workspace data is available", () => { + const loadedSkills = [skill("repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: loadedSkills, + isPending: false, + currentKey: null, + currentSkills: [], + }), + ).toBe(loadedSkills); + }); + + it("preserves current skills while refreshing the same workspace", () => { + const currentSkills = [skill("repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/repo", + currentSkills, + }), + ).toBe(currentSkills); + }); + + it("clears current skills while loading a different workspace", () => { + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/new-repo", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/old-repo", + currentSkills: [skill("old-repo-skill")], + }), + ).toEqual([]); + }); + + it("clears skills after a non-pending query with no data", () => { + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: null, + isPending: false, + currentKey: "environment:codex:/repo", + currentSkills: [skill("repo-local")], + }), + ).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index dafa99ecf43..0fe102c36f5 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -43,6 +43,18 @@ export function resolvePendingProviderWorkspaceSkills(input: { : EMPTY_SKILLS; } +export function resolveProviderWorkspaceSkills(input: { + readonly nextKey: string; + readonly nextSkills: ReadonlyArray | null; + readonly isPending: boolean; + readonly currentKey: string | null; + readonly currentSkills: ReadonlyArray; +}): ReadonlyArray { + if (input.nextSkills !== null) return input.nextSkills; + if (!input.isPending) return EMPTY_SKILLS; + return resolvePendingProviderWorkspaceSkills(input); +} + export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { @@ -74,12 +86,27 @@ export function useProviderWorkspaceSkills( previousFallbackSkillsRef.current = target.fallbackSkills; if (key !== null) query.refresh(); }, [key, query, target.fallbackSkills]); + const previousWorkspaceSkillsRef = useRef<{ + readonly key: string; + readonly skills: ReadonlyArray; + } | null>(null); + useEffect(() => { + if (key === null || query.data === null) return; + previousWorkspaceSkillsRef.current = { key, skills: query.data.skills }; + }, [key, query.data]); if (key === null) { return { skills: target.fallbackSkills, isPending: false, error: null }; } + const previousWorkspaceSkills = previousWorkspaceSkillsRef.current; return { - skills: query.data?.skills ?? EMPTY_SKILLS, + skills: resolveProviderWorkspaceSkills({ + nextKey: key, + nextSkills: query.data?.skills ?? null, + isPending: query.isPending, + currentKey: previousWorkspaceSkills?.key ?? null, + currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_SKILLS, + }), isPending: query.isPending, error: query.error, }; From 05e81b614e753468826ca34bf46de225fc36ed55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 19 Jun 2026 18:18:40 +0100 Subject: [PATCH 10/11] Harden provider workspace skill cache - Preserve and clear cached workspace skills by query state - Cover pending data, empty data, and workspace switches --- .../lib/providerWorkspaceSkillsState.test.ts | 125 ++++++++++++++++++ .../src/lib/providerWorkspaceSkillsState.ts | 67 +++++++--- 2 files changed, 172 insertions(+), 20 deletions(-) diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts index 26c30ae0dc1..d301f251ac2 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -2,6 +2,7 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + resolveNextProviderWorkspaceSkillsSnapshot, resolvePendingProviderWorkspaceSkills, resolveProviderWorkspaceSkills, } from "./providerWorkspaceSkillsState"; @@ -53,6 +54,35 @@ describe("resolveProviderWorkspaceSkills", () => { ).toBe(loadedSkills); }); + it("uses loaded skills even when the query is still pending", () => { + const loadedSkills = [skill("repo-local")]; + const currentSkills = [skill("stale-repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: loadedSkills, + isPending: true, + currentKey: "environment:codex:/repo", + currentSkills, + }), + ).toBe(loadedSkills); + }); + + it("uses an empty loaded skill list as available workspace data", () => { + const loadedSkills: ReadonlyArray = []; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: loadedSkills, + isPending: true, + currentKey: "environment:codex:/repo", + currentSkills: [skill("repo-local")], + }), + ).toBe(loadedSkills); + }); + it("preserves current skills while refreshing the same workspace", () => { const currentSkills = [skill("repo-local")]; @@ -79,6 +109,39 @@ describe("resolveProviderWorkspaceSkills", () => { ).toEqual([]); }); + it("does not leak skills during rapid workspace switches", () => { + const repoASkills = [skill("repo-a")]; + const repoBSkills = [skill("repo-b")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo-b", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/repo-a", + currentSkills: repoASkills, + }), + ).toEqual([]); + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo-a", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/repo-b", + currentSkills: repoBSkills, + }), + ).toEqual([]); + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo-a", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/repo-a", + currentSkills: repoASkills, + }), + ).toBe(repoASkills); + }); + it("clears skills after a non-pending query with no data", () => { expect( resolveProviderWorkspaceSkills({ @@ -91,3 +154,65 @@ describe("resolveProviderWorkspaceSkills", () => { ).toEqual([]); }); }); + +describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { + it("stores settled workspace skills for the active key", () => { + const loadedSkills = [skill("repo-local")]; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: loadedSkills, + isPending: false, + current: null, + }), + ).toEqual({ + key: "environment:codex:/repo", + skills: loadedSkills, + }); + }); + + it("preserves the current snapshot while pending", () => { + const current = { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: [skill("fresh-repo-local")], + isPending: true, + current, + }), + ).toBe(current); + }); + + it("clears the snapshot when the target is disabled", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: null, + skills: [skill("repo-local")], + isPending: false, + current: { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }, + }), + ).toBeNull(); + }); + + it("clears the snapshot after a settled query without data", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: null, + isPending: false, + current: { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index 0fe102c36f5..fbfd7b3584e 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -20,6 +20,22 @@ export interface ProviderWorkspaceSkillsState { const EMPTY_SKILLS: ReadonlyArray = []; +export interface ProviderWorkspaceSkillsSnapshotInput { + readonly currentKey: string | null; + readonly nextKey: string; + readonly currentSkills: ReadonlyArray; +} + +export interface ProviderWorkspaceSkillsResolutionInput extends ProviderWorkspaceSkillsSnapshotInput { + readonly nextSkills: ReadonlyArray | null; + readonly isPending: boolean; +} + +export interface ProviderWorkspaceSkillsSnapshot { + readonly key: string; + readonly skills: ReadonlyArray; +} + function targetKey(target: Omit): string | null { if ( !target.enabled || @@ -33,28 +49,37 @@ function targetKey(target: Omit return `${target.environmentId}:${target.instanceId}:${target.cwd.trim()}`; } -export function resolvePendingProviderWorkspaceSkills(input: { - readonly currentKey: string | null; - readonly nextKey: string; - readonly currentSkills: ReadonlyArray; -}): ReadonlyArray { +export function resolvePendingProviderWorkspaceSkills( + input: ProviderWorkspaceSkillsSnapshotInput, +): ReadonlyArray { return input.currentKey === input.nextKey && input.currentSkills.length > 0 ? input.currentSkills : EMPTY_SKILLS; } -export function resolveProviderWorkspaceSkills(input: { - readonly nextKey: string; - readonly nextSkills: ReadonlyArray | null; - readonly isPending: boolean; - readonly currentKey: string | null; - readonly currentSkills: ReadonlyArray; -}): ReadonlyArray { +/** + * Query result arrays are readonly cache values, so these helpers preserve references + * and rely on callers to keep them immutable. + */ +export function resolveProviderWorkspaceSkills( + input: ProviderWorkspaceSkillsResolutionInput, +): ReadonlyArray { if (input.nextSkills !== null) return input.nextSkills; if (!input.isPending) return EMPTY_SKILLS; return resolvePendingProviderWorkspaceSkills(input); } +export function resolveNextProviderWorkspaceSkillsSnapshot(input: { + readonly key: string | null; + readonly skills: ReadonlyArray | null; + readonly isPending: boolean; + readonly current: ProviderWorkspaceSkillsSnapshot | null; +}): ProviderWorkspaceSkillsSnapshot | null { + if (input.key === null) return null; + if (input.skills === null) return input.isPending ? input.current : null; + return input.isPending ? input.current : { key: input.key, skills: input.skills }; +} + export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { @@ -86,14 +111,16 @@ export function useProviderWorkspaceSkills( previousFallbackSkillsRef.current = target.fallbackSkills; if (key !== null) query.refresh(); }, [key, query, target.fallbackSkills]); - const previousWorkspaceSkillsRef = useRef<{ - readonly key: string; - readonly skills: ReadonlyArray; - } | null>(null); + const previousWorkspaceSkillsRef = useRef(null); + const querySkills = query.data?.skills ?? null; useEffect(() => { - if (key === null || query.data === null) return; - previousWorkspaceSkillsRef.current = { key, skills: query.data.skills }; - }, [key, query.data]); + previousWorkspaceSkillsRef.current = resolveNextProviderWorkspaceSkillsSnapshot({ + key, + skills: querySkills, + isPending: query.isPending, + current: previousWorkspaceSkillsRef.current, + }); + }, [key, query.isPending, querySkills]); if (key === null) { return { skills: target.fallbackSkills, isPending: false, error: null }; @@ -102,7 +129,7 @@ export function useProviderWorkspaceSkills( return { skills: resolveProviderWorkspaceSkills({ nextKey: key, - nextSkills: query.data?.skills ?? null, + nextSkills: querySkills, isPending: query.isPending, currentKey: previousWorkspaceSkills?.key ?? null, currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_SKILLS, From e5496d67f8c76ba6ba3b24c84d3cbb57595da392 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 11:51:35 -0700 Subject: [PATCH 11/11] Cover provider skill discovery failures Co-authored-by: codex --- apps/server/scripts/acp-mock-agent.ts | 5 + .../scripts/codex-skills-mock-app-server.ts | 81 +++++++++++++ .../src/provider/Layers/CodexProvider.test.ts | 111 +++++++++++++++++- .../provider/Layers/CursorProvider.test.ts | 37 ++++++ .../src/provider/Layers/GrokProvider.test.ts | 45 +++++++ .../src/provider/ProviderSkillsLister.ts | 46 +++++--- 6 files changed, 307 insertions(+), 18 deletions(-) create mode 100644 apps/server/scripts/codex-skills-mock-app-server.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 2b5da74eef0..64a763bb54e 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -13,6 +13,7 @@ import type * as AcpSchema from "effect-acp/schema"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; +const cwdLogPath = process.env.T3_ACP_CWD_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; @@ -30,6 +31,10 @@ const permissionOptionIds = { }; const sessionId = "mock-session-1"; +if (cwdLogPath) { + appendFileSync(cwdLogPath, `${process.cwd()}\n`, "utf8"); +} + let currentModeId = "ask"; let currentModelId = "default"; let parameterizedModelPicker = false; diff --git a/apps/server/scripts/codex-skills-mock-app-server.ts b/apps/server/scripts/codex-skills-mock-app-server.ts new file mode 100644 index 00000000000..77cf02d3252 --- /dev/null +++ b/apps/server/scripts/codex-skills-mock-app-server.ts @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +import { appendFileSync } from "node:fs"; + +const cwdLogPath = process.env.T3_CODEX_CWD_LOG_PATH; +const exitLogPath = process.env.T3_CODEX_EXIT_LOG_PATH; +const hangSkillsList = process.env.T3_CODEX_HANG_SKILLS_LIST === "1"; + +function appendLog(path: string | undefined, line: string): void { + if (path) appendFileSync(path, `${line}\n`, "utf8"); +} + +function respond(id: number | string, result: unknown): void { + process.stdout.write(`${JSON.stringify({ id, result })}\n`); +} + +process.once("SIGTERM", () => { + appendLog(exitLogPath, "SIGTERM"); + process.exit(0); +}); +process.once("exit", (code) => appendLog(exitLogPath, `exit:${code}`)); + +let remainder = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + remainder += chunk; + const lines = remainder.split("\n"); + remainder = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + const message = JSON.parse(line) as Record; + const id = message.id; + if ((typeof id !== "number" && typeof id !== "string") || typeof message.method !== "string") { + continue; + } + + switch (message.method) { + case "initialize": + appendLog(cwdLogPath, process.cwd()); + respond(id, { + userAgent: "t3code-codex-skills-test", + codexHome: process.cwd(), + platformFamily: "unix", + platformOs: "linux", + }); + break; + case "account/read": + respond(id, { + account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, + requiresOpenaiAuth: false, + }); + break; + case "skills/list": + if (!hangSkillsList) { + respond(id, { + data: [ + { + cwd: process.cwd(), + errors: [], + skills: [ + { + name: "workspace-skill", + description: "A workspace-scoped test skill.", + shortDescription: "Workspace test skill", + path: `${process.cwd()}/.agents/skills/workspace-skill/SKILL.md`, + scope: "repo", + enabled: true, + }, + ], + }, + ], + }); + } + break; + default: + respond(id, {}); + } + } +}); +process.stdin.on("end", () => process.exit(0)); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0e21b76306b..52287e59bc8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,60 @@ -import { assert, it } from "@effect/vitest"; +import * as NodeOS from "node:os"; +import { setTimeout as delay } from "node:timers/promises"; -import { mapCodexModelCapabilities } from "./CodexProvider.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; + +import { listCodexProviderSkills, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { listCodexProviderSkillsWithTimeout } from "../ProviderSkillsLister.ts"; + +const resolveMockAppServerPath = Effect.fn("resolveMockAppServerPath")(function* () { + const path = yield* Path.Path; + return yield* path.fromFileUrl( + new URL("../../../scripts/codex-skills-mock-app-server.ts", import.meta.url), + ); +}); + +const makeMockAppServer = Effect.fn("makeMockAppServer")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAppServerPath = yield* resolveMockAppServerPath(); + const directory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "codex-skills-provider-", + }); + const binaryPath = path.join(directory, "codex"); + const command = [process.execPath, mockAppServerPath] + .map((argument) => JSON.stringify(argument)) + .join(" "); + yield* fileSystem.writeFileString(binaryPath, `#!/bin/sh\nexec ${command} "$@"\n`); + yield* fileSystem.chmod(binaryPath, 0o755); + const workspaceDirectory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "codex-skills-workspace-", + }); + return { + binaryPath, + cwd: yield* fileSystem.realPath(workspaceDirectory), + cwdLogPath: path.join(directory, "cwd.log"), + exitLogPath: path.join(directory, "exit.log"), + }; +}); + +const waitForFileContent = Effect.fn("waitForFileContent")(function* (filePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + for (let attempt = 0; attempt < 40; attempt += 1) { + const content = yield* fileSystem.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + if (content.trim()) return content; + yield* Effect.promise(() => delay(50)); + } + return yield* Effect.die(`Timed out waiting for file content at ${filePath}`); +}); it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -102,3 +156,56 @@ it("uses standard routing when the catalog has no default service tier", () => { }, ]); }); + +describe("listCodexProviderSkills", () => { + it.effect("lists workspace skills from the configured cwd", () => + Effect.gen(function* () { + const fixture = yield* makeMockAppServer(); + const skills = yield* listCodexProviderSkills({ + binaryPath: fixture.binaryPath, + cwd: fixture.cwd, + environment: { + ...process.env, + T3_CODEX_CWD_LOG_PATH: fixture.cwdLogPath, + }, + }).pipe(Effect.scoped); + + expect(skills).toEqual([ + { + name: "workspace-skill", + description: "A workspace-scoped test skill.", + shortDescription: "Workspace test skill", + path: `${fixture.cwd}/.agents/skills/workspace-skill/SKILL.md`, + scope: "repo", + enabled: true, + }, + ]); + expect((yield* waitForFileContent(fixture.cwdLogPath)).trim()).toBe(fixture.cwd); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reports timeouts and terminates the app-server", () => + Effect.gen(function* () { + const fixture = yield* makeMockAppServer(); + const fiber = yield* listCodexProviderSkillsWithTimeout({ + instanceId: ProviderInstanceId.make("codex"), + binaryPath: fixture.binaryPath, + cwd: fixture.cwd, + environment: { + ...process.env, + T3_CODEX_CWD_LOG_PATH: fixture.cwdLogPath, + T3_CODEX_EXIT_LOG_PATH: fixture.exitLogPath, + T3_CODEX_HANG_SKILLS_LIST: "1", + }, + }).pipe(Effect.forkChild); + + yield* waitForFileContent(fixture.cwdLogPath); + yield* TestClock.adjust("15 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + expect(error.message).toBe( + `Timed out listing Codex skills after 15s (provider: 'codex', cwd: '${fixture.cwd}').`, + ); + expect(yield* waitForFileContent(fixture.exitLogPath)).toContain("SIGTERM"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index b7de1ee6cb4..b0bea27cd1e 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -441,6 +441,43 @@ describe("checkCursorProviderStatus", () => { }); describe("discoverCursorModelsViaAcp", () => { + it("starts the ACP process in the configured cwd", async () => { + const fixture = await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-cwd-", + }); + const workspaceDirectory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-workspace-", + }); + return { + cwd: yield* fileSystem.realPath(workspaceDirectory), + cwdLogPath: path.join(directory, "cwd.log"), + wrapperPath: yield* makeMockAgentWrapper(), + }; + }), + ); + + await runNode( + discoverCursorModelsViaAcp( + { + enabled: true, + binaryPath: fixture.wrapperPath, + apiEndpoint: "", + customModels: [], + }, + fixture.cwd, + { ...process.env, T3_ACP_CWD_LOG_PATH: fixture.cwdLogPath }, + ).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + await expect(runNode(waitForFileContent(fixture.cwdLogPath))).resolves.toBe(`${fixture.cwd}\n`); + }); + it("keeps the ACP probe runtime alive long enough to discover models", async () => { const wrapperPath = await runNode(makeMockAgentWrapper()); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 71f01fdbaf1..ca247ec4195 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -1,3 +1,5 @@ +import * as NodeOS from "node:os"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -37,6 +39,49 @@ describe("buildInitialGrokProviderSnapshot", () => { }); it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { + it.effect("starts ACP model discovery in the configured cwd", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-grok-cwd-" }); + const workspaceDirectory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "t3code-grok-workspace-", + }); + const cwd = yield* fileSystem.realPath(workspaceDirectory); + const cwdLogPath = path.join(directory, "cwd.log"); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const grokPath = path.join(directory, "grok"); + const command = [process.execPath, mockAgentPath] + .map((argument) => JSON.stringify(argument)) + .join(" "); + yield* fileSystem.writeFileString( + grokPath, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "grok-cli 0.0.99\\n"', + " exit 0", + "fi", + `exec ${command} "$@"`, + "", + ].join("\n"), + ); + yield* fileSystem.chmod(grokPath, 0o755); + + const snapshot = yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + cwd, + { ...process.env, T3_ACP_CWD_LOG_PATH: cwdLogPath }, + ); + + expect(snapshot.status).toBe("ready"); + expect((yield* fileSystem.readFileString(cwdLogPath)).trim()).toBe(cwd); + }).pipe(Effect.scoped), + ); + it.effect("reports the binary as missing when the binary path does not resolve", () => Effect.gen(function* () { const snapshot = yield* checkGrokProviderStatus( diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts index 6079456e610..ed39a029e68 100644 --- a/apps/server/src/provider/ProviderSkillsLister.ts +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -75,6 +75,33 @@ function describeCodexSkillListFailure( return `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${input.cwd}').`; } +export const listCodexProviderSkillsWithTimeout = Effect.fn("listCodexProviderSkillsWithTimeout")( + function* (input: { + readonly instanceId: ProviderInstanceId; + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + }) { + return yield* listCodexProviderSkills({ + binaryPath: input.binaryPath, + ...(input.homePath ? { homePath: input.homePath } : {}), + cwd: input.cwd, + environment: input.environment, + }).pipe( + Effect.scoped, + Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: describeCodexSkillListFailure(cause, input), + cause, + }), + ), + ); + }, +); + function requestKey(input: ProviderSkillsListInput): string { return JSON.stringify([input.instanceId, input.cwd]); } @@ -162,26 +189,13 @@ export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(fu }), ), ); - const skills = yield* listCodexProviderSkills({ + const skills = yield* listCodexProviderSkillsWithTimeout({ + instanceId: input.instanceId, binaryPath: effectiveConfig.binaryPath, ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), cwd: normalizedCwd, environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), - }).pipe( - Effect.scoped, - Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: describeCodexSkillListFailure(cause, { - instanceId: input.instanceId, - cwd: normalizedCwd, - }), - cause, - }), - ), - ); + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner)); return { skills }; });