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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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.", });