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..a9bd16d7780 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -267,6 +267,43 @@ 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; + // 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, + ...(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 +475,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 +516,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/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 0f2a8f790bf..d9d0e4d00e2 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"; @@ -72,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"; @@ -102,6 +119,24 @@ 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); @@ -128,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], @@ -139,6 +175,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 +280,10 @@ 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; @@ -267,6 +308,98 @@ 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}.`, @@ -1000,6 +1133,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..3d83c7f2ae3 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, @@ -755,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], @@ -895,6 +900,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cwd: isPathTrigger ? gitCwd : null, query: isPathTrigger ? pathTriggerQuery : null, }); + const providerWorkspaceSkills = useProviderWorkspaceSkills({ + environmentId, + instanceId: selectedProviderStatus?.instanceId ?? null, + cwd: gitCwd, + enabled: true, + fallbackSkills: selectedProviderFallbackSkills, + }); const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; @@ -950,7 +962,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 +977,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,15 +1048,16 @@ 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."; + 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 @@ -2307,7 +2326,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.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 new file mode 100644 index 00000000000..c0fd2ee02d8 --- /dev/null +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -0,0 +1,214 @@ +import type { EnvironmentId, ProviderInstanceId, ServerProviderSkill } from "@t3tools/contracts"; +import { useEffect, useMemo, useRef, 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; +} + +interface InternalProviderWorkspaceSkillsState extends ProviderWorkspaceSkillsState { + readonly key: string | null; +} + +const cache = new Map>(); +const CACHE_MAX_ENTRIES = 100; +const EMPTY_SKILLS: ReadonlyArray = []; + +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.trim().length === 0 + ) { + return null; + } + 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; + readonly currentSkills: ReadonlyArray; +}): ReadonlyArray { + return input.currentKey === input.nextKey && input.currentSkills.length > 0 + ? input.currentSkills + : EMPTY_SKILLS; +} + +export function useProviderWorkspaceSkills( + target: ProviderWorkspaceSkillsTarget, +): ProviderWorkspaceSkillsState { + const fallbackSkillsRef = useRef(target.fallbackSkills); + const stableTarget = useMemo( + () => ({ + environmentId: target.environmentId, + instanceId: target.instanceId, + cwd: target.cwd?.trim() || null, + enabled: target.enabled, + }), + [target.cwd, target.enabled, target.environmentId, target.instanceId], + ); + const key = targetKey(stableTarget); + const [connectionVersion, setConnectionVersion] = useState(0); + 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]); + + useEffect( + () => subscribeWorkspaceSkillChanges(() => setConnectionVersion((version) => version + 1)), + [], + ); + + 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) { + 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.", + }); + return; + } + + let cancelled = false; + setState((current) => ({ + key, + skills: resolvePendingProviderWorkspaceSkills({ + currentKey: current.key, + nextKey: key, + currentSkills: current.skills, + }), + isPending: true, + error: null, + })); + void connection.client.server + .listProviderSkills({ + instanceId: stableTarget.instanceId, + cwd: stableTarget.cwd, + }) + .then((result) => { + if (cancelled) return; + setCachedSkills(key, result.skills); + setState({ key, skills: result.skills, isPending: false, error: null }); + }) + .catch((error: unknown) => { + if (cancelled) return; + setState({ + key, + skills: EMPTY_SKILLS, + 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, + ]); + + return { + skills: state.skills, + isPending: state.isPending, + error: state.error, + }; +} 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. *