diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 2b5da74eef0..64a763bb54e 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -13,6 +13,7 @@ import type * as AcpSchema from "effect-acp/schema"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; +const cwdLogPath = process.env.T3_ACP_CWD_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; @@ -30,6 +31,10 @@ const permissionOptionIds = { }; const sessionId = "mock-session-1"; +if (cwdLogPath) { + appendFileSync(cwdLogPath, `${process.cwd()}\n`, "utf8"); +} + let currentModeId = "ask"; let currentModelId = "default"; let parameterizedModelPicker = false; diff --git a/apps/server/scripts/codex-skills-mock-app-server.ts b/apps/server/scripts/codex-skills-mock-app-server.ts new file mode 100644 index 00000000000..77cf02d3252 --- /dev/null +++ b/apps/server/scripts/codex-skills-mock-app-server.ts @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +import { appendFileSync } from "node:fs"; + +const cwdLogPath = process.env.T3_CODEX_CWD_LOG_PATH; +const exitLogPath = process.env.T3_CODEX_EXIT_LOG_PATH; +const hangSkillsList = process.env.T3_CODEX_HANG_SKILLS_LIST === "1"; + +function appendLog(path: string | undefined, line: string): void { + if (path) appendFileSync(path, `${line}\n`, "utf8"); +} + +function respond(id: number | string, result: unknown): void { + process.stdout.write(`${JSON.stringify({ id, result })}\n`); +} + +process.once("SIGTERM", () => { + appendLog(exitLogPath, "SIGTERM"); + process.exit(0); +}); +process.once("exit", (code) => appendLog(exitLogPath, `exit:${code}`)); + +let remainder = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + remainder += chunk; + const lines = remainder.split("\n"); + remainder = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + const message = JSON.parse(line) as Record; + const id = message.id; + if ((typeof id !== "number" && typeof id !== "string") || typeof message.method !== "string") { + continue; + } + + switch (message.method) { + case "initialize": + appendLog(cwdLogPath, process.cwd()); + respond(id, { + userAgent: "t3code-codex-skills-test", + codexHome: process.cwd(), + platformFamily: "unix", + platformOs: "linux", + }); + break; + case "account/read": + respond(id, { + account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, + requiresOpenaiAuth: false, + }); + break; + case "skills/list": + if (!hangSkillsList) { + respond(id, { + data: [ + { + cwd: process.cwd(), + errors: [], + skills: [ + { + name: "workspace-skill", + description: "A workspace-scoped test skill.", + shortDescription: "Workspace test skill", + path: `${process.cwd()}/.agents/skills/workspace-skill/SKILL.md`, + scope: "repo", + enabled: true, + }, + ], + }, + ], + }); + } + break; + default: + respond(id, {}); + } + } +}); +process.stdin.on("end", () => process.exit(0)); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77d..ed8b59fd8b2 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -119,6 +119,7 @@ export const CodexDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); @@ -166,7 +167,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 c394a7d1b43..a5df2de9eb2 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -106,6 +106,7 @@ export const CursorDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -130,7 +131,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 d855d1a4515..3ba0396642c 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -88,6 +88,7 @@ export const GrokDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -112,7 +113,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.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0e21b76306b..52287e59bc8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,60 @@ -import { assert, it } from "@effect/vitest"; +import * as NodeOS from "node:os"; +import { setTimeout as delay } from "node:timers/promises"; -import { mapCodexModelCapabilities } from "./CodexProvider.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; + +import { listCodexProviderSkills, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { listCodexProviderSkillsWithTimeout } from "../ProviderSkillsLister.ts"; + +const resolveMockAppServerPath = Effect.fn("resolveMockAppServerPath")(function* () { + const path = yield* Path.Path; + return yield* path.fromFileUrl( + new URL("../../../scripts/codex-skills-mock-app-server.ts", import.meta.url), + ); +}); + +const makeMockAppServer = Effect.fn("makeMockAppServer")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAppServerPath = yield* resolveMockAppServerPath(); + const directory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "codex-skills-provider-", + }); + const binaryPath = path.join(directory, "codex"); + const command = [process.execPath, mockAppServerPath] + .map((argument) => JSON.stringify(argument)) + .join(" "); + yield* fileSystem.writeFileString(binaryPath, `#!/bin/sh\nexec ${command} "$@"\n`); + yield* fileSystem.chmod(binaryPath, 0o755); + const workspaceDirectory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "codex-skills-workspace-", + }); + return { + binaryPath, + cwd: yield* fileSystem.realPath(workspaceDirectory), + cwdLogPath: path.join(directory, "cwd.log"), + exitLogPath: path.join(directory, "exit.log"), + }; +}); + +const waitForFileContent = Effect.fn("waitForFileContent")(function* (filePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + for (let attempt = 0; attempt < 40; attempt += 1) { + const content = yield* fileSystem.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + if (content.trim()) return content; + yield* Effect.promise(() => delay(50)); + } + return yield* Effect.die(`Timed out waiting for file content at ${filePath}`); +}); it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -102,3 +156,56 @@ it("uses standard routing when the catalog has no default service tier", () => { }, ]); }); + +describe("listCodexProviderSkills", () => { + it.effect("lists workspace skills from the configured cwd", () => + Effect.gen(function* () { + const fixture = yield* makeMockAppServer(); + const skills = yield* listCodexProviderSkills({ + binaryPath: fixture.binaryPath, + cwd: fixture.cwd, + environment: { + ...process.env, + T3_CODEX_CWD_LOG_PATH: fixture.cwdLogPath, + }, + }).pipe(Effect.scoped); + + expect(skills).toEqual([ + { + name: "workspace-skill", + description: "A workspace-scoped test skill.", + shortDescription: "Workspace test skill", + path: `${fixture.cwd}/.agents/skills/workspace-skill/SKILL.md`, + scope: "repo", + enabled: true, + }, + ]); + expect((yield* waitForFileContent(fixture.cwdLogPath)).trim()).toBe(fixture.cwd); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reports timeouts and terminates the app-server", () => + Effect.gen(function* () { + const fixture = yield* makeMockAppServer(); + const fiber = yield* listCodexProviderSkillsWithTimeout({ + instanceId: ProviderInstanceId.make("codex"), + binaryPath: fixture.binaryPath, + cwd: fixture.cwd, + environment: { + ...process.env, + T3_CODEX_CWD_LOG_PATH: fixture.cwdLogPath, + T3_CODEX_EXIT_LOG_PATH: fixture.exitLogPath, + T3_CODEX_HANG_SKILLS_LIST: "1", + }, + }).pipe(Effect.forkChild); + + yield* waitForFileContent(fixture.cwdLogPath); + yield* TestClock.adjust("15 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + expect(error.message).toBe( + `Timed out listing Codex skills after 15s (provider: 'codex', cwd: '${fixture.cwd}').`, + ); + expect(yield* waitForFileContent(fixture.exitLogPath)).toContain("SIGTERM"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index fb2f36f6438..8343a97e1b9 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -270,6 +270,59 @@ 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 spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { + env: environment, + extendEnv: true, + }); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), + ); + + 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: { @@ -459,6 +512,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; @@ -500,7 +554,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: resolvedEnvironment, }).pipe( diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 60a7312eea3..b0bea27cd1e 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, @@ -440,16 +441,56 @@ describe("checkCursorProviderStatus", () => { }); describe("discoverCursorModelsViaAcp", () => { + it("starts the ACP process in the configured cwd", async () => { + const fixture = await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-cwd-", + }); + const workspaceDirectory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-workspace-", + }); + return { + cwd: yield* fileSystem.realPath(workspaceDirectory), + cwdLogPath: path.join(directory, "cwd.log"), + wrapperPath: yield* makeMockAgentWrapper(), + }; + }), + ); + + await runNode( + discoverCursorModelsViaAcp( + { + enabled: true, + binaryPath: fixture.wrapperPath, + apiEndpoint: "", + customModels: [], + }, + fixture.cwd, + { ...process.env, T3_ACP_CWD_LOG_PATH: fixture.cwdLogPath }, + ).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + await expect(runNode(waitForFileContent(fixture.cwdLogPath))).resolves.toBe(`${fixture.cwd}\n`); + }); + it("keeps the ACP probe runtime alive long enough to discover models", async () => { const wrapperPath = await runNode(makeMockAgentWrapper()); 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 +507,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 12eb6054145..7d186873812 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -395,6 +395,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( const makeCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, + cwd: string, environment?: NodeJS.ProcessEnv, ) => Effect.gen(function* () { @@ -407,10 +408,10 @@ const makeCursorAcpProbeRuntime = ( ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), "acp", ], - cwd: process.cwd(), + cwd, ...(environment ? { 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, @@ -421,10 +422,11 @@ const makeCursorAcpProbeRuntime = ( const withCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, + cwd: string, useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, environment?: NodeJS.ProcessEnv, ) => - makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( + makeCursorAcpProbeRuntime(cursorSettings, cwd, environment).pipe( Effect.flatMap(useRuntime), Effect.scoped, ); @@ -543,10 +545,12 @@ export function resolveCursorAcpConfigUpdates( const discoverCursorModelsViaListAvailableModels = ( cursorSettings: CursorSettings, + cwd: string, environment?: NodeJS.ProcessEnv, ) => withCursorAcpProbeRuntime( cursorSettings, + cwd, (acp) => Effect.gen(function* () { yield* acp.start(); @@ -559,8 +563,9 @@ const discoverCursorModelsViaListAvailableModels = ( export const discoverCursorModelsViaAcp = ( cursorSettings: CursorSettings, + cwd: string, environment?: NodeJS.ProcessEnv, -) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); +) => discoverCursorModelsViaListAvailableModels(cursorSettings, cwd, environment); export function getCursorFallbackModels( cursorSettings: Pick, @@ -970,6 +975,7 @@ const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: Nod export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, + cwd: string, environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, @@ -1065,7 +1071,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..ca247ec4195 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -1,3 +1,5 @@ +import * as NodeOS from "node:os"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -37,6 +39,49 @@ describe("buildInitialGrokProviderSnapshot", () => { }); it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { + it.effect("starts ACP model discovery in the configured cwd", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-grok-cwd-" }); + const workspaceDirectory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "t3code-grok-workspace-", + }); + const cwd = yield* fileSystem.realPath(workspaceDirectory); + const cwdLogPath = path.join(directory, "cwd.log"); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const grokPath = path.join(directory, "grok"); + const command = [process.execPath, mockAgentPath] + .map((argument) => JSON.stringify(argument)) + .join(" "); + yield* fileSystem.writeFileString( + grokPath, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "grok-cli 0.0.99\\n"', + " exit 0", + "fi", + `exec ${command} "$@"`, + "", + ].join("\n"), + ); + yield* fileSystem.chmod(grokPath, 0o755); + + const snapshot = yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + cwd, + { ...process.env, T3_ACP_CWD_LOG_PATH: cwdLogPath }, + ); + + expect(snapshot.status).toBe("ready"); + expect((yield* fileSystem.readFileString(cwdLogPath)).trim()).toBe(cwd); + }).pipe(Effect.scoped), + ); + it.effect("reports the binary as missing when the binary path does not resolve", () => Effect.gen(function* () { const snapshot = yield* checkGrokProviderStatus( @@ -44,6 +89,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 +114,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + process.cwd(), ); }), ); @@ -95,6 +142,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 b1c84fb3a03..c164ab36f7e 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -132,6 +132,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( const discoverGrokModelsViaAcp = ( grokSettings: GrokSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, ) => Effect.gen(function* () { @@ -140,7 +141,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(); @@ -167,6 +168,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); @@ -249,7 +251,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 5fe0f903686..533057a27c5 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"); @@ -1412,7 +1422,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/provider/ProviderSkillsLister.test.ts b/apps/server/src/provider/ProviderSkillsLister.test.ts new file mode 100644 index 00000000000..38233740ea4 --- /dev/null +++ b/apps/server/src/provider/ProviderSkillsLister.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; + +import { makeBoundedRequestCache } from "./ProviderSkillsLister.ts"; + +describe("makeBoundedRequestCache", () => { + it.effect("coalesces concurrent requests for the same key", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const release = yield* Deferred.make(); + const requests = yield* makeBoundedRequestCache({ + capacity: 8, + concurrency: 2, + timeToLive: "1 second", + lookup: (key: string) => + Ref.update(calls, (count) => count + 1).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(`value:${key}`), + ), + }); + + const first = yield* Effect.forkChild(requests.get("repo")); + const second = yield* Effect.forkChild(requests.get("repo")); + yield* Effect.yieldNow; + expect(yield* Ref.get(calls)).toBe(1); + + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(first)).toBe("value:repo"); + expect(yield* Fiber.join(second)).toBe("value:repo"); + expect(yield* Ref.get(calls)).toBe(1); + }), + ); + + it.effect("bounds concurrent lookups across different keys", () => + Effect.gen(function* () { + const active = yield* Ref.make(0); + const maxActive = yield* Ref.make(0); + const release = yield* Deferred.make(); + const requests = yield* makeBoundedRequestCache({ + capacity: 8, + concurrency: 2, + timeToLive: "1 second", + lookup: (key: string) => + Effect.acquireUseRelease( + Ref.updateAndGet(active, (count) => count + 1).pipe( + Effect.tap((count) => Ref.update(maxActive, (maximum) => Math.max(maximum, count))), + ), + () => Deferred.await(release).pipe(Effect.as(key)), + () => Ref.update(active, (count) => count - 1), + ), + }); + + const fibers = yield* Effect.forEach(["one", "two", "three"], requests.get, { + concurrency: "unbounded", + discard: false, + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(yield* Ref.get(maxActive)).toBe(2); + + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(fibers)).toEqual(["one", "two", "three"]); + }), + ); +}); diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts new file mode 100644 index 00000000000..ed39a029e68 --- /dev/null +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -0,0 +1,212 @@ +import { + CodexSettings, + ServerProviderSkillsListError, + type ProviderInstanceId, + type ServerProviderSkillsListResult, +} from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { materializeCodexShadowHome, resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts"; +import { listCodexProviderSkills } from "./Layers/CodexProvider.ts"; +import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts"; +import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; + +const CODEX_SKILL_LIST_TIMEOUT = Duration.seconds(15); +const PROVIDER_SKILLS_CACHE_CAPACITY = 64; +const PROVIDER_SKILLS_CACHE_TTL = Duration.seconds(1); +const PROVIDER_SKILLS_MAX_CONCURRENCY = 4; +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); + +export interface ProviderSkillsListInput { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; +} + +export interface BoundedRequestCache { + readonly get: (key: Key) => Effect.Effect; +} + +export const makeBoundedRequestCache = Effect.fn("makeBoundedRequestCache")(function* < + Key, + A, + E, + R, +>(options: { + readonly capacity: number; + readonly concurrency: number; + readonly timeToLive: Duration.Input; + readonly lookup: (key: Key) => Effect.Effect; +}): Effect.fn.Return, never, R> { + const semaphore = yield* Semaphore.make(options.concurrency); + const cache = yield* Cache.make({ + capacity: options.capacity, + timeToLive: options.timeToLive, + lookup: (key: Key) => semaphore.withPermits(1)(options.lookup(key)), + }); + return { + get: (key) => Cache.get(cache, key), + }; +}); + +function describeUnknownCause(cause: unknown): string { + if (cause instanceof Error) return cause.message; + if (typeof cause === "string") return cause; + return "Unknown error"; +} + +function describeCodexSkillListFailure( + cause: unknown, + input: { readonly instanceId: string; readonly cwd: string }, +): string { + if (Cause.isTimeoutError(cause)) { + return `Timed out listing Codex skills after ${Duration.toSeconds(CODEX_SKILL_LIST_TIMEOUT)}s (provider: '${input.instanceId}', cwd: '${input.cwd}').`; + } + return `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${input.cwd}').`; +} + +export const listCodexProviderSkillsWithTimeout = Effect.fn("listCodexProviderSkillsWithTimeout")( + function* (input: { + readonly instanceId: ProviderInstanceId; + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + }) { + return yield* listCodexProviderSkills({ + binaryPath: input.binaryPath, + ...(input.homePath ? { homePath: input.homePath } : {}), + cwd: input.cwd, + environment: input.environment, + }).pipe( + Effect.scoped, + Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: describeCodexSkillListFailure(cause, input), + cause, + }), + ), + ); + }, +); + +function requestKey(input: ProviderSkillsListInput): string { + return JSON.stringify([input.instanceId, input.cwd]); +} + +function parseRequestKey(key: string): ProviderSkillsListInput { + const [instanceId, cwd] = JSON.parse(key) as [ProviderInstanceId, string]; + return { instanceId, cwd }; +} + +export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(function* () { + const providerRegistry = yield* ProviderRegistry; + const serverSettings = yield* ServerSettingsService; + const workspacePaths = yield* WorkspacePaths; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const listUncached = Effect.fn("ProviderSkillsLister.listUncached")(function* ( + input: ProviderSkillsListInput, + ): Effect.fn.Return { + const providers = yield* providerRegistry.getProviders; + const snapshot = providers.find((provider) => provider.instanceId === input.instanceId); + if (!snapshot) { + return yield* new ServerProviderSkillsListError({ + message: `Provider instance '${input.instanceId}' was not found.`, + }); + } + if (snapshot.driver !== "codex") { + return { skills: snapshot.skills }; + } + + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: "Failed to read provider settings.", + cause, + }), + ), + ); + const instanceConfig = deriveProviderInstanceConfigMap(settings)[input.instanceId]; + if (!instanceConfig || instanceConfig.driver !== "codex") { + return yield* new ServerProviderSkillsListError({ + message: `Codex provider instance '${input.instanceId}' is not configured.`, + }); + } + + const decodedConfig = yield* decodeCodexSettings(instanceConfig.config ?? {}).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Failed to decode Codex provider settings for '${input.instanceId}'.`, + cause, + }), + ), + ); + const effectiveConfig = { + ...decodedConfig, + enabled: instanceConfig.enabled ?? decodedConfig.enabled, + }; + if (!effectiveConfig.enabled) { + return { skills: snapshot.skills }; + } + + const normalizedCwd = yield* workspacePaths.normalizeWorkspaceRoot(input.cwd).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Invalid Codex skills cwd '${input.cwd}': ${cause.message}`, + cause, + }), + ), + ); + const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( + Effect.provideService(Path.Path, path), + ); + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Failed to prepare Codex home for '${input.instanceId}': ${describeUnknownCause(cause)}`, + cause, + }), + ), + ); + const skills = yield* listCodexProviderSkillsWithTimeout({ + instanceId: input.instanceId, + binaryPath: effectiveConfig.binaryPath, + ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), + cwd: normalizedCwd, + environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner)); + return { skills }; + }); + + const requests = yield* makeBoundedRequestCache({ + capacity: PROVIDER_SKILLS_CACHE_CAPACITY, + concurrency: PROVIDER_SKILLS_MAX_CONCURRENCY, + timeToLive: PROVIDER_SKILLS_CACHE_TTL, + lookup: (key: string) => listUncached(parseRequestKey(key)), + }); + + return Effect.fn("ProviderSkillsLister.list")(function* (input: ProviderSkillsListInput) { + return yield* requests.get(requestKey(input)); + }); +}); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 205833289ea..cd9836ac4b5 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -25,6 +25,8 @@ import { ProviderDriverKind, ProviderInstanceId, ResolvedKeybindingRule, + type ServerProvider, + type ServerProviderSkill, ThreadId, WS_METHODS, WsRpcGroup, @@ -1143,6 +1145,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); @@ -4279,6 +4299,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 1ad37e7c49b..f178799821b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -44,6 +44,8 @@ import { FilesystemBrowseError, AssetAccessError, EnvironmentAuthorizationError, + ServerProviderSkillsListError, + type ServerProviderSkillsListResult, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -69,6 +71,10 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import { + makeProviderSkillsLister, + type ProviderSkillsListInput, +} from "./provider/ProviderSkillsLister.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; @@ -110,7 +116,6 @@ import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< @@ -147,6 +152,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], @@ -248,7 +254,12 @@ function toAuthAccessStreamEvent( } } -const makeWsRpcLayer = (currentSession: AuthenticatedSession) => +const makeWsRpcLayer = ( + currentSession: AuthenticatedSession, + listProviderSkills: ( + input: ProviderSkillsListInput, + ) => Effect.Effect, +) => WsRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; @@ -1039,6 +1050,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, @@ -1639,8 +1654,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ); export const websocketRpcRouteLayer = Layer.unwrap( - Effect.succeed( - HttpRouter.add( + Effect.gen(function* () { + const listProviderSkills = yield* makeProviderSkillsLister(); + return HttpRouter.add( "GET", "/ws", Effect.gen(function* () { @@ -1657,7 +1673,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session).pipe( + makeWsRpcLayer(session, listProviderSkills).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ProviderMaintenanceRunner.layer), @@ -1696,6 +1712,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( EnvironmentInternalError: HttpServerRespondable.toResponse, }), ), - ), - ), + ); + }), ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 3a5e06bce06..188fa677466 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -58,6 +58,7 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; +import { useProviderWorkspaceSkills } from "../../lib/providerWorkspaceSkillsState"; import { type ElementContextDraft } from "../../lib/elementContext"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; @@ -126,6 +127,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; +const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const runtimeModeConfig: Record< RuntimeMode, @@ -786,6 +788,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); + const selectedProviderFallbackSkills = selectedProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS; const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -937,6 +940,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 []; @@ -992,7 +1002,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, @@ -1007,7 +1017,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 @@ -1072,15 +1088,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 @@ -2413,7 +2430,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/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts new file mode 100644 index 00000000000..d301f251ac2 --- /dev/null +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -0,0 +1,218 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + resolveNextProviderWorkspaceSkillsSnapshot, + resolvePendingProviderWorkspaceSkills, + resolveProviderWorkspaceSkills, +} 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([]); + }); +}); + +describe("resolveProviderWorkspaceSkills", () => { + it("uses loaded skills as soon as workspace data is available", () => { + const loadedSkills = [skill("repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: loadedSkills, + isPending: false, + currentKey: null, + currentSkills: [], + }), + ).toBe(loadedSkills); + }); + + it("uses loaded skills even when the query is still pending", () => { + const loadedSkills = [skill("repo-local")]; + const currentSkills = [skill("stale-repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: loadedSkills, + isPending: true, + currentKey: "environment:codex:/repo", + currentSkills, + }), + ).toBe(loadedSkills); + }); + + it("uses an empty loaded skill list as available workspace data", () => { + const loadedSkills: ReadonlyArray = []; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: loadedSkills, + isPending: true, + currentKey: "environment:codex:/repo", + currentSkills: [skill("repo-local")], + }), + ).toBe(loadedSkills); + }); + + it("preserves current skills while refreshing the same workspace", () => { + const currentSkills = [skill("repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/repo", + currentSkills, + }), + ).toBe(currentSkills); + }); + + it("clears current skills while loading a different workspace", () => { + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/new-repo", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/old-repo", + currentSkills: [skill("old-repo-skill")], + }), + ).toEqual([]); + }); + + it("does not leak skills during rapid workspace switches", () => { + const repoASkills = [skill("repo-a")]; + const repoBSkills = [skill("repo-b")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo-b", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/repo-a", + currentSkills: repoASkills, + }), + ).toEqual([]); + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo-a", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/repo-b", + currentSkills: repoBSkills, + }), + ).toEqual([]); + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo-a", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/repo-a", + currentSkills: repoASkills, + }), + ).toBe(repoASkills); + }); + + it("clears skills after a non-pending query with no data", () => { + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: null, + isPending: false, + currentKey: "environment:codex:/repo", + currentSkills: [skill("repo-local")], + }), + ).toEqual([]); + }); +}); + +describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { + it("stores settled workspace skills for the active key", () => { + const loadedSkills = [skill("repo-local")]; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: loadedSkills, + isPending: false, + current: null, + }), + ).toEqual({ + key: "environment:codex:/repo", + skills: loadedSkills, + }); + }); + + it("preserves the current snapshot while pending", () => { + const current = { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: [skill("fresh-repo-local")], + isPending: true, + current, + }), + ).toBe(current); + }); + + it("clears the snapshot when the target is disabled", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: null, + skills: [skill("repo-local")], + isPending: false, + current: { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }, + }), + ).toBeNull(); + }); + + it("clears the snapshot after a settled query without data", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: null, + isPending: false, + current: { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts new file mode 100644 index 00000000000..fbfd7b3584e --- /dev/null +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -0,0 +1,140 @@ +import type { EnvironmentId, ProviderInstanceId, ServerProviderSkill } from "@t3tools/contracts"; +import { useEffect, useMemo, useRef } from "react"; + +import { serverEnvironment } from "../state/server"; +import { useEnvironmentQuery } from "../state/query"; + +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 EMPTY_SKILLS: ReadonlyArray = []; + +export interface ProviderWorkspaceSkillsSnapshotInput { + readonly currentKey: string | null; + readonly nextKey: string; + readonly currentSkills: ReadonlyArray; +} + +export interface ProviderWorkspaceSkillsResolutionInput extends ProviderWorkspaceSkillsSnapshotInput { + readonly nextSkills: ReadonlyArray | null; + readonly isPending: boolean; +} + +export interface ProviderWorkspaceSkillsSnapshot { + readonly key: string; + readonly skills: ReadonlyArray; +} + +function targetKey(target: Omit): string | null { + if ( + !target.enabled || + 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 resolvePendingProviderWorkspaceSkills( + input: ProviderWorkspaceSkillsSnapshotInput, +): ReadonlyArray { + return input.currentKey === input.nextKey && input.currentSkills.length > 0 + ? input.currentSkills + : EMPTY_SKILLS; +} + +/** + * Query result arrays are readonly cache values, so these helpers preserve references + * and rely on callers to keep them immutable. + */ +export function resolveProviderWorkspaceSkills( + input: ProviderWorkspaceSkillsResolutionInput, +): ReadonlyArray { + if (input.nextSkills !== null) return input.nextSkills; + if (!input.isPending) return EMPTY_SKILLS; + return resolvePendingProviderWorkspaceSkills(input); +} + +export function resolveNextProviderWorkspaceSkillsSnapshot(input: { + readonly key: string | null; + readonly skills: ReadonlyArray | null; + readonly isPending: boolean; + readonly current: ProviderWorkspaceSkillsSnapshot | null; +}): ProviderWorkspaceSkillsSnapshot | null { + if (input.key === null) return null; + if (input.skills === null) return input.isPending ? input.current : null; + return input.isPending ? input.current : { key: input.key, skills: input.skills }; +} + +export function useProviderWorkspaceSkills( + target: ProviderWorkspaceSkillsTarget, +): ProviderWorkspaceSkillsState { + 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 query = useEnvironmentQuery( + key !== null && stableTarget.environmentId !== null && stableTarget.instanceId !== null + ? serverEnvironment.providerSkills({ + environmentId: stableTarget.environmentId, + input: { + instanceId: stableTarget.instanceId, + cwd: stableTarget.cwd!, + }, + }) + : null, + ); + + const previousFallbackSkillsRef = useRef(target.fallbackSkills); + useEffect(() => { + if (previousFallbackSkillsRef.current === target.fallbackSkills) return; + previousFallbackSkillsRef.current = target.fallbackSkills; + if (key !== null) query.refresh(); + }, [key, query, target.fallbackSkills]); + const previousWorkspaceSkillsRef = useRef(null); + const querySkills = query.data?.skills ?? null; + useEffect(() => { + previousWorkspaceSkillsRef.current = resolveNextProviderWorkspaceSkillsSnapshot({ + key, + skills: querySkills, + isPending: query.isPending, + current: previousWorkspaceSkillsRef.current, + }); + }, [key, query.isPending, querySkills]); + + if (key === null) { + return { skills: target.fallbackSkills, isPending: false, error: null }; + } + const previousWorkspaceSkills = previousWorkspaceSkillsRef.current; + return { + skills: resolveProviderWorkspaceSkills({ + nextKey: key, + nextSkills: querySkills, + isPending: query.isPending, + currentKey: previousWorkspaceSkills?.key ?? null, + currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_SKILLS, + }), + isPending: query.isPending, + error: query.error, + }; +} diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 2fbf183f91b..e93b747093f 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -66,6 +66,7 @@ function createBrowserLocalApi(): LocalApi { server: { getConfig: () => Promise.reject(unavailableLocalBackendError()), refreshProviders: () => Promise.reject(unavailableLocalBackendError()), + listProviderSkills: () => Promise.reject(unavailableLocalBackendError()), updateProvider: () => Promise.reject(unavailableLocalBackendError()), upsertKeybinding: () => Promise.reject(unavailableLocalBackendError()), removeKeybinding: () => Promise.reject(unavailableLocalBackendError()), diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 7584e55d52e..35da6705228 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -144,14 +144,19 @@ describe("environmentRpcKey", () => { const environmentId = EnvironmentId.make("environment-1"); const originalTarget = { environmentId, - input: { cwd: "/repo/original" }, + input: { instanceId: "codex", cwd: "/repo/original" }, }; const nextTarget = { environmentId, - input: { cwd: "/repo/next" }, + input: { instanceId: "codex", cwd: "/repo/next" }, + }; + const nextProviderTarget = { + environmentId, + input: { instanceId: "codex-secondary", cwd: "/repo/original" }, }; expect(environmentRpcKey(originalTarget)).not.toBe(environmentRpcKey(nextTarget)); + expect(environmentRpcKey(originalTarget)).not.toBe(environmentRpcKey(nextProviderTarget)); expect(environmentRpcKey(originalTarget)).toBe(environmentRpcKey({ ...originalTarget })); expect( environmentRpcKey({ diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 23bb7bff2a9..f103d8ea346 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -133,6 +133,11 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, }), + providerSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:provider-skills", + tag: WS_METHODS.serverListProviderSkills, + staleTimeMs: 30_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f9377d6bf8b..afb7774031e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -37,6 +37,8 @@ import type { ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, ServerProcessResourceHistoryResult, + ServerProviderSkillsListInput, + ServerProviderSkillsListResult, ServerProviderUpdateInput, ServerProviderUpdatedPayload, ServerRemoveKeybindingResult, @@ -1034,6 +1036,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 87c5a49c73b..54cd233ef2a 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -119,6 +119,9 @@ import { ServerLifecycleStreamEvent, ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, + ServerProviderSkillsListError, + ServerProviderSkillsListInput, + ServerProviderSkillsListResult, ServerProviderUpdatedPayload, ServerTraceDiagnosticsResult, ServerProcessDiagnosticsResult, @@ -201,6 +204,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", @@ -264,6 +268,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, @@ -681,6 +691,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. *