From 74319e0af2f1c3050906a40a1e7d2e62465c6f9f Mon Sep 17 00:00:00 2001 From: Lorenzo Date: Mon, 6 Jul 2026 18:06:29 -0300 Subject: [PATCH] feat: slash commands and skills from local repo --- .../threads/ComposerCommandPopover.tsx | 12 +- .../src/features/threads/ThreadComposer.tsx | 42 ++++-- .../features/threads/ThreadDetailScreen.tsx | 20 +-- .../src/features/threads/ThreadFeed.tsx | 2 + .../threads/new-task-flow-provider.tsx | 16 +- apps/mobile/src/state/queries.ts | 36 ++++- .../src/provider/Drivers/ClaudeDriver.ts | 40 +++++ .../src/provider/Drivers/CodexDriver.ts | 57 +++++++- .../src/provider/Drivers/CursorDriver.ts | 41 ++++++ .../src/provider/Drivers/OpenCodeDriver.ts | 41 ++++++ apps/server/src/provider/Errors.ts | 19 +++ .../src/provider/Layers/ClaudeProvider.ts | 102 ++++++++++++- .../src/provider/Layers/CodexProvider.ts | 52 ++++++- .../provider/Layers/CursorProvider.test.ts | 19 +++ .../src/provider/Layers/CursorProvider.ts | 52 +++++++ .../src/provider/Layers/OpenCodeProvider.ts | 71 ++++++++- .../provider/Layers/ProviderRegistry.test.ts | 137 ++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 30 ++++ apps/server/src/provider/ProviderDriver.ts | 13 +- .../src/provider/Services/ProviderRegistry.ts | 13 ++ .../src/provider/projectCapabilities.ts | 31 ++++ .../providerMaintenanceRunner.test.ts | 7 + .../testUtils/providerRegistryMock.ts | 7 + apps/server/src/ws.ts | 18 +++ apps/web/src/components/ChatView.tsx | 10 +- apps/web/src/components/chat/ChatComposer.tsx | 46 ++++-- apps/web/src/state/queries.ts | 30 ++++ packages/client-runtime/package.json | 4 + .../src/state/projectCommands.ts | 6 + .../src/state/providerCapabilities.test.ts | 82 +++++++++++ .../src/state/providerCapabilities.ts | 58 ++++++++ packages/contracts/src/rpc.ts | 13 ++ packages/contracts/src/server.test.ts | 43 +++++- packages/contracts/src/server.ts | 48 ++++++ 34 files changed, 1160 insertions(+), 58 deletions(-) create mode 100644 apps/server/src/provider/projectCapabilities.ts create mode 100644 packages/client-runtime/src/state/providerCapabilities.test.ts create mode 100644 packages/client-runtime/src/state/providerCapabilities.ts diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 87ac2a6f951..abb4d3f3150 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -42,6 +42,7 @@ interface ComposerCommandPopoverProps { readonly items: ReadonlyArray; readonly triggerKind: ComposerTriggerKind | null; readonly isLoading: boolean; + readonly errorText?: string | null; readonly onSelect: (item: ComposerCommandItem) => void; } @@ -111,7 +112,14 @@ function groupLabel(triggerKind: ComposerTriggerKind | null): string | null { } } -function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean): string { +function emptyText( + triggerKind: ComposerTriggerKind | null, + isLoading: boolean, + errorText: string | null | undefined, +): string { + if (errorText) { + return errorText; + } if (isLoading) { return triggerKind === "path" ? "Searching files…" : "Loading…"; } @@ -214,7 +222,7 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( ) : ( - {emptyText(props.triggerKind, props.isLoading)} + {emptyText(props.triggerKind, props.isLoading, props.errorText)} )} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index bc107b0c3d2..25086715ee8 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -60,6 +60,7 @@ import { resolveProviderOptionDescriptors, } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; +import { useProviderProjectCapabilities } from "../../state/queries"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; /** @@ -305,15 +306,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); const toolbarFadeOpaque = isDarkMode ? "rgba(0,0,0,0.95)" : "rgba(255,255,255,0.95)"; const toolbarFadeTransparent = isDarkMode ? "rgba(0,0,0,0)" : "rgba(255,255,255,0)"; - const selectedProviderStatus = useMemo(() => { - if (!props.serverConfig) return null; - return ( - props.serverConfig.providers.find( - (p) => p.instanceId === props.selectedThread.modelSelection.instanceId, - ) ?? null - ); - }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); - // ── Trigger detection ──────────────────────────────────── const [composerSelection, setComposerSelection] = useState(() => ({ start: props.draftMessage.length, @@ -346,6 +338,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, query: composerTrigger?.kind === "path" ? composerTrigger.query : null, }); + const providerProjectCapabilities = useProviderProjectCapabilities({ + environmentId: props.environmentId, + providerInstanceId: props.selectedThread.modelSelection.instanceId, + cwd: props.projectCwd, + providers: props.serverConfig?.providers ?? null, + }); const composerMenuItems: ComposerCommandItem[] = useMemo(() => { if (!composerTrigger) return []; @@ -378,7 +376,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const builtIn = allBuiltIn.filter((item) => item.command.includes(q)); const providerCommands: ComposerCommandItem[] = []; - for (const cmd of selectedProviderStatus?.slashCommands ?? []) { + for (const cmd of providerProjectCapabilities.slashCommands) { if (!cmd.name.toLowerCase().includes(q)) continue; providerCommands.push({ id: `pcmd:${cmd.name}`, @@ -393,7 +391,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } if (composerTrigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); + const enabledSkills = providerProjectCapabilities.skills.filter((s) => s.enabled); const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { trimLeadingPattern: /^\$+/, }); @@ -490,7 +488,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); + }, [ + composerTrigger, + pathSearch.entries, + providerProjectCapabilities.skills, + providerProjectCapabilities.slashCommands, + ]); + const isComposerCommandPopoverLoading = + composerTrigger?.kind === "path" + ? pathSearch.isPending + : composerTrigger?.kind === "skill" || composerTrigger?.kind === "slash-command" + ? providerProjectCapabilities.isPending + : false; + const composerCommandPopoverError = + composerTrigger?.kind === "path" ? pathSearch.error : providerProjectCapabilities.error; // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; @@ -690,7 +701,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer layout={COMPOSER_LAYOUT_TRANSITION} style={{ alignSelf: "center", maxWidth: props.contentMaxWidth, position: "relative" }} > - {composerTrigger && composerMenuItems.length > 0 ? ( + {composerTrigger ? ( @@ -758,7 +770,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ref={inputRef} multiline value={props.draftMessage} - skills={selectedProviderStatus?.skills ?? []} + skills={providerProjectCapabilities.skills} selection={composerSelection} onChangeText={props.onChangeDraftMessage} onSelectionChange={handleSelectionChange} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index f9f6e878b96..58e923aa4d9 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -16,7 +16,7 @@ import type { } from "@t3tools/contracts"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; import * as Haptics from "expo-haptics"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Platform, View, type GestureResponderEvent } from "react-native"; import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated"; @@ -36,6 +36,7 @@ import type { } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; import { PendingUserInputCard } from "./PendingUserInputCard"; +import { useProviderProjectCapabilities } from "../../state/queries"; import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, @@ -271,12 +272,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const selectedProviderSkills = useMemo( - () => - props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) - ?.skills ?? [], - [props.serverConfig, selectedInstanceId], - ); + const projectCapabilityCwd = props.threadCwd ?? props.projectWorkspaceRoot; + const selectedProviderCapabilities = useProviderProjectCapabilities({ + environmentId: props.environmentId, + providerInstanceId: selectedInstanceId, + cwd: projectCapabilityCwd, + providers: props.serverConfig?.providers ?? null, + }); useLayoutEffect(() => { selectedThreadKeyRef.current = selectedThreadKey; @@ -415,7 +417,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} - skills={selectedProviderSkills} + skills={selectedProviderCapabilities.skills} /> ) : ( @@ -484,7 +486,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread queueCount={props.selectedThreadQueueCount} activeThreadBusy={props.activeThreadBusy} environmentId={props.environmentId} - projectCwd={props.projectWorkspaceRoot} + projectCwd={projectCapabilityCwd} bottomInset={composerBottomInset} onChangeDraftMessage={props.onChangeDraftMessage} onPickDraftImages={props.onPickDraftImages} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index c95af3979b8..e9ac7867e23 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1259,6 +1259,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { iconSubtleColor, markdownStyles, reviewCommentColors, + skills: props.skills, userBubbleColor, viewportWidth, }), @@ -1267,6 +1268,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { expandedWorkRows, iconSubtleColor, markdownStyles, + props.skills, reviewCommentColors, userBubbleColor, viewportWidth, diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index ee2bde9d971..a49e5fc44bf 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -37,7 +37,7 @@ import { updateComposerDraftSettings, useComposerDraft, } from "../../state/use-composer-drafts"; -import { useBranches } from "../../state/queries"; +import { useBranches, useProviderProjectCapabilities } from "../../state/queries"; import { flattenQueuedThreadMessages, threadOutboxManager, @@ -381,13 +381,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderSkills = useMemo( - () => - selectedEnvironmentServerConfig?.providers.find( - (provider) => provider.instanceId === selectedModel?.instanceId, - )?.skills ?? [], - [selectedEnvironmentServerConfig, selectedModel?.instanceId], - ); + const selectedProviderCapabilities = useProviderProjectCapabilities({ + environmentId: selectedProject?.environmentId ?? null, + providerInstanceId: selectedModel?.instanceId, + cwd: selectedWorktreePath || selectedProject?.workspaceRoot || null, + providers: selectedEnvironmentServerConfig?.providers ?? null, + }); + const selectedProviderSkills = selectedProviderCapabilities.skills; const setSelectedModelKey = useCallback( (key: string | null) => { if (!key || !selectedProjectDraftKey) { diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index ea625995928..ac99059f12c 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,4 +1,13 @@ -import type { EnvironmentId, OrchestrationThread, ThreadId } from "@t3tools/contracts"; +import type { + EnvironmentId, + OrchestrationThread, + ProviderInstanceId, + ServerProvider, + ServerProviderSlashCommand, + ServerProviderSkill, + ThreadId, +} from "@t3tools/contracts"; +import { buildProviderProjectCapabilitiesQueryTarget } from "@t3tools/client-runtime/state/provider-capabilities"; import * as Option from "effect/Option"; import { useEffect, useMemo, useState } from "react"; @@ -16,6 +25,8 @@ import { const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200; const COMPOSER_PATH_SEARCH_LIMIT = 20; const VCS_REF_LIST_LIMIT = 100; +const EMPTY_PROVIDER_SKILLS: ReadonlyArray = []; +const EMPTY_PROVIDER_SLASH_COMMANDS: ReadonlyArray = []; export interface ThreadDetailView { readonly data: OrchestrationThread | null; @@ -111,6 +122,29 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { }; } +export function useProviderProjectCapabilities(target: { + readonly environmentId: EnvironmentId | null; + readonly providerInstanceId: ProviderInstanceId | null | undefined; + readonly cwd: string | null | undefined; + readonly providers?: ReadonlyArray | null; +}) { + const queryTarget = useMemo( + () => buildProviderProjectCapabilitiesQueryTarget(target), + [target.cwd, target.environmentId, target.providerInstanceId, target.providers], + ); + const result = useEnvironmentQuery( + queryTarget === null ? null : projectEnvironment.projectCapabilities(queryTarget), + ); + + return { + skills: result.data?.skills ?? EMPTY_PROVIDER_SKILLS, + slashCommands: result.data?.slashCommands ?? EMPTY_PROVIDER_SLASH_COMMANDS, + error: result.error, + isPending: result.isPending, + refresh: result.refresh, + }; +} + export function useCheckpointDiff(target: CheckpointDiffTarget) { const targets = useMemo( () => buildCheckpointDiffTargets(target), diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index f2b04b3a282..48a9c1b4b97 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -32,9 +32,11 @@ import { checkClaudeProviderStatus, makePendingClaudeProvider, probeClaudeCapabilities, + probeClaudeProjectCapabilities, } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeProviderProjectCapabilitiesError } from "../projectCapabilities.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -59,6 +61,7 @@ const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); const DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); const CAPABILITIES_PROBE_TTL = Duration.minutes(5); +const PROJECT_CAPABILITIES_TTL = Duration.seconds(30); function isClaudeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); @@ -159,6 +162,16 @@ export const ClaudeDriver: ProviderDriver = { ), }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig); + const projectCapabilitiesCache = yield* Cache.makeWith( + (cwd: string) => + probeClaudeProjectCapabilities(effectiveConfig, processEnv, cwd).pipe( + Effect.provideService(Path.Path, path), + ), + { + capacity: 64, + timeToLive: () => PROJECT_CAPABILITIES_TTL, + }, + ); const checkProvider = checkClaudeProviderStatus( effectiveConfig, @@ -212,6 +225,33 @@ export const ClaudeDriver: ProviderDriver = { snapshot, adapter, textGeneration, + composerCapabilities: { + list: (input) => + Effect.gen(function* () { + const capabilities = input.forceReload + ? yield* probeClaudeProjectCapabilities( + effectiveConfig, + processEnv, + input.cwd, + ).pipe(Effect.provideService(Path.Path, path)) + : yield* Cache.get(projectCapabilitiesCache, input.cwd); + return { + providerInstanceId: instanceId, + cwd: input.cwd, + slashCommands: capabilities?.slashCommands ?? [], + skills: capabilities?.skills ?? [], + }; + }).pipe( + Effect.mapError((cause) => + makeProviderProjectCapabilitiesError({ + provider: DRIVER_KIND, + instanceId, + cwd: input.cwd, + cause, + }), + ), + ), + }, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77d..1eba500cf80 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -22,6 +22,7 @@ * @module provider/Drivers/CodexDriver */ import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -36,9 +37,14 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; -import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { + checkCodexProviderStatus, + listCodexProjectSkills, + makePendingCodexProvider, +} from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeProviderProjectCapabilitiesError } from "../projectCapabilities.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; @@ -61,6 +67,7 @@ const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const PROJECT_CAPABILITIES_TTL = Duration.seconds(30); const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: "@openai/codex", @@ -161,6 +168,22 @@ export const CodexDriver: ProviderDriver = { ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); + const projectSkillsCache = yield* Cache.makeWith( + (cwd: string) => + listCodexProjectSkills({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + cwd, + environment: processEnv, + }).pipe( + Effect.scoped, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + { + capacity: 64, + timeToLive: () => PROJECT_CAPABILITIES_TTL, + }, + ); // Build a managed snapshot whose settings never change — mutations come // in as instance rebuilds from the registry rather than in-place @@ -209,6 +232,38 @@ export const CodexDriver: ProviderDriver = { snapshot, adapter, textGeneration, + composerCapabilities: { + list: (input) => + Effect.gen(function* () { + const skills = input.forceReload + ? yield* listCodexProjectSkills({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + cwd: input.cwd, + forceReload: true, + environment: processEnv, + }).pipe( + Effect.scoped, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ) + : yield* Cache.get(projectSkillsCache, input.cwd); + return { + providerInstanceId: instanceId, + cwd: input.cwd, + slashCommands: [], + skills, + }; + }).pipe( + Effect.mapError((cause) => + makeProviderProjectCapabilitiesError({ + provider: DRIVER_KIND, + instanceId, + cwd: input.cwd, + cause, + }), + ), + ), + }, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index c394a7d1b43..1ea33f2c8b6 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -12,6 +12,7 @@ * @module provider/Drivers/CursorDriver */ import { CursorSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -30,9 +31,11 @@ import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus, enrichCursorSnapshot, + probeCursorProjectSlashCommands, } from "../Layers/CursorProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeProviderProjectCapabilitiesError } from "../projectCapabilities.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -54,6 +57,7 @@ const decodeCursorSettings = Schema.decodeSync(CursorSettings); const DRIVER_KIND = ProviderDriverKind.make("cursor"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const PROJECT_CAPABILITIES_TTL = Duration.seconds(30); const UPDATE = makeStaticProviderMaintenanceResolver( makeProviderMaintenanceCapabilities({ provider: DRIVER_KIND, @@ -129,6 +133,16 @@ export const CursorDriver: ProviderDriver = { instanceId, }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); + const projectSlashCommandCache = yield* Cache.makeWith( + (cwd: string) => + probeCursorProjectSlashCommands(effectiveConfig, cwd, processEnv).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + { + capacity: 64, + timeToLive: () => PROJECT_CAPABILITIES_TTL, + }, + ); const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( Effect.map(stampIdentity), @@ -181,6 +195,33 @@ export const CursorDriver: ProviderDriver = { snapshot, adapter, textGeneration, + composerCapabilities: { + list: (input) => + Effect.gen(function* () { + const slashCommands = input.forceReload + ? yield* probeCursorProjectSlashCommands( + effectiveConfig, + input.cwd, + processEnv, + ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) + : yield* Cache.get(projectSlashCommandCache, input.cwd); + return { + providerInstanceId: instanceId, + cwd: input.cwd, + slashCommands, + skills: [], + }; + }).pipe( + Effect.mapError((cause) => + makeProviderProjectCapabilitiesError({ + provider: DRIVER_KIND, + instanceId, + cwd: input.cwd, + cause, + }), + ), + ), + }, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 6342d176590..1d372976972 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -13,6 +13,7 @@ * @module provider/Drivers/OpenCodeDriver */ import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -29,11 +30,13 @@ import { ProviderDriverError } from "../Errors.ts"; import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; import { checkOpenCodeProviderStatus, + listOpenCodeProjectSlashCommands, makePendingOpenCodeProvider, } from "../Layers/OpenCodeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { OpenCodeRuntime } from "../opencodeRuntime.ts"; +import { makeProviderProjectCapabilitiesError } from "../projectCapabilities.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -56,6 +59,7 @@ const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const PROJECT_CAPABILITIES_TTL = Duration.seconds(30); function isOpenCodeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); @@ -142,6 +146,16 @@ export const OpenCodeDriver: ProviderDriver ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), }); const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv); + const projectSlashCommandCache = yield* Cache.makeWith( + (cwd: string) => + listOpenCodeProjectSlashCommands(effectiveConfig, cwd, processEnv).pipe( + Effect.provideService(OpenCodeRuntime, openCodeRuntime), + ), + { + capacity: 64, + timeToLive: () => PROJECT_CAPABILITIES_TTL, + }, + ); const checkProvider = checkOpenCodeProviderStatus( effectiveConfig, @@ -190,6 +204,33 @@ export const OpenCodeDriver: ProviderDriver snapshot, adapter, textGeneration, + composerCapabilities: { + list: (input) => + Effect.gen(function* () { + const slashCommands = input.forceReload + ? yield* listOpenCodeProjectSlashCommands( + effectiveConfig, + input.cwd, + processEnv, + ).pipe(Effect.provideService(OpenCodeRuntime, openCodeRuntime)) + : yield* Cache.get(projectSlashCommandCache, input.cwd); + return { + providerInstanceId: instanceId, + cwd: input.cwd, + slashCommands, + skills: [], + }; + }).pipe( + Effect.mapError((cause) => + makeProviderProjectCapabilitiesError({ + provider: DRIVER_KIND, + instanceId, + cwd: input.cwd, + cause, + }), + ), + ), + }, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index 0cf1522399b..d4b37afc0ad 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -156,6 +156,25 @@ export class ProviderDriverError extends Schema.TaggedErrorClass()( + "ProviderProjectCapabilitiesError", + { + provider: Schema.String, + instanceId: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Provider '${this.provider}' failed to list project capabilities for instance '${this.instanceId}' in '${this.cwd}': ${this.detail}`; + } +} + /** * ProviderSessionNotFoundError - Provider-facing session not found. */ diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 59b7d0464de..25218e99367 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -4,8 +4,10 @@ import { type ModelSelection, ProviderDriverKind, type ServerProviderModel, + type ServerProviderSkill, type ServerProviderSlashCommand, } from "@t3tools/contracts"; +import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -52,6 +54,16 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; +class ClaudeCapabilitiesProbeTimeoutError extends Data.TaggedError( + "ClaudeCapabilitiesProbeTimeoutError", +)<{ + readonly timeoutMs: number; +}> { + override get message(): string { + return `Claude capability probe timed out after ${this.timeoutMs}ms.`; + } +} + const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-fable-5", @@ -493,6 +505,7 @@ type ClaudeCapabilitiesProbe = { readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; readonly slashCommands: ReadonlyArray; + readonly skills: ReadonlyArray; }; function parseClaudeInitializationCommands( @@ -558,6 +571,57 @@ function dedupeSlashCommands( return [...commandsByName.values()]; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readProbeString(value: unknown): string | undefined { + return typeof value === "string" ? nonEmptyProbeString(value) : undefined; +} + +function parseClaudeInitializationSkills(input: unknown): ReadonlyArray { + if (!Array.isArray(input)) { + return []; + } + + const skillsByName = new Map(); + for (const item of input) { + if (!isRecord(item)) { + continue; + } + const name = readProbeString(item.name); + const path = readProbeString(item.path); + if (!name || !path) { + continue; + } + + const description = readProbeString(item.description); + const shortDescription = + readProbeString(item.shortDescription) ?? + readProbeString(item.short_description) ?? + readProbeString(item.summary); + const displayName = + readProbeString(item.displayName) ?? + readProbeString(item.display_name) ?? + readProbeString(item.title); + const scope = + readProbeString(item.scope) ?? readProbeString(item.source) ?? readProbeString(item.type); + const enabled = typeof item.enabled === "boolean" ? item.enabled : true; + + skillsByName.set(name.toLowerCase(), { + name, + path, + enabled, + ...(description ? { description } : {}), + ...(shortDescription ? { shortDescription } : {}), + ...(displayName ? { displayName } : {}), + ...(scope ? { scope } : {}), + }); + } + + return [...skillsByName.values()]; +} + function waitForAbortSignal(signal: AbortSignal): Promise { if (signal.aborted) { return Promise.resolve(); @@ -580,9 +644,10 @@ function waitForAbortSignal(signal: AbortSignal): Promise { * This is used as a fallback when `claude auth status` does not include * subscription type information. */ -const probeClaudeCapabilities = ( +const runClaudeCapabilitiesProbe = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, + cwd?: string, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -596,6 +661,7 @@ const probeClaudeCapabilities = ( await waitForAbortSignal(abort.signal); })(), options: { + ...(cwd ? { cwd, additionalDirectories: [cwd] } : {}), persistSession: false, pathToClaudeCodeExecutable: claudeSettings.binaryPath, abortController: abort, @@ -618,6 +684,7 @@ const probeClaudeCapabilities = ( subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, slashCommands: parseClaudeInitializationCommands(init.commands), + skills: parseClaudeInitializationSkills((init as { readonly skills?: unknown }).skills), } satisfies ClaudeCapabilitiesProbe; }); }).pipe( @@ -626,14 +693,41 @@ const probeClaudeCapabilities = ( if (!abort.signal.aborted) abort.abort(); }), ), + ); +}; + +const probeClaudeProjectCapabilities = ( + claudeSettings: ClaudeSettings, + environment?: NodeJS.ProcessEnv, + cwd?: string, +) => + runClaudeCapabilitiesProbe(claudeSettings, environment, cwd).pipe( Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new ClaudeCapabilitiesProbeTimeoutError({ + timeoutMs: CAPABILITIES_PROBE_TIMEOUT_MS, + }), + ), + onSome: Effect.succeed, + }), + ), + ); + +const probeClaudeCapabilities = ( + claudeSettings: ClaudeSettings, + environment?: NodeJS.ProcessEnv, + cwd?: string, +) => + probeClaudeProjectCapabilities(claudeSettings, environment, cwd).pipe( Effect.result, Effect.map((result) => { if (Result.isFailure(result)) return undefined; - return Option.isSome(result.success) ? result.success.value : undefined; + return result.success; }), ); -}; const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( claudeSettings: ClaudeSettings, @@ -861,4 +955,4 @@ export const makePendingClaudeProvider = ( }); }); -export { probeClaudeCapabilities }; +export { probeClaudeCapabilities, probeClaudeProjectCapabilities }; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 811c362f1e0..a22521e260c 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -217,7 +217,7 @@ function appendCustomCodexModels( return customEntries.length === 0 ? models : [...models, ...customEntries]; } -function parseCodexSkillsListResponse( +export function parseCodexSkillsListResponse( response: CodexSchema.V2SkillsListResponse, cwd: string, ): ReadonlyArray { @@ -373,6 +373,56 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; }); +export const listCodexProjectSkills = Effect.fn("listCodexProjectSkills")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + readonly forceReload?: boolean; + 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 response = yield* client.request("skills/list", { + cwds: [input.cwd], + ...(input.forceReload ? { forceReload: true } : {}), + }); + return parseCodexSkillsListResponse(response, input.cwd); +}); + const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { const models = new Set(); for (const model of codexSettings.customModels) { diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 78f62ac2123..22a17064ac7 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -20,6 +20,7 @@ import { parseCursorAboutOutput, parseCursorCliConfigChannel, parseCursorVersionDate, + probeCursorProjectSlashCommands, resolveCursorAcpBaseModelId, resolveCursorAcpConfigUpdates, } from "./CursorProvider.ts"; @@ -513,6 +514,24 @@ describe("discoverCursorModelsViaAcp", () => { }); }); +describe("probeCursorProjectSlashCommands", () => { + it("fails immediately when ACP startup fails", async () => { + await expect( + runNode( + probeCursorProjectSlashCommands( + { + enabled: true, + binaryPath: "/definitely-missing-cursor-agent", + apiEndpoint: "", + customModels: [], + }, + process.cwd(), + ), + ), + ).rejects.toThrow(); + }); +}); + describe("parseCursorAboutOutput", () => { it("parses json about output and forwards subscription metadata", () => { expect( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index cd9b93a4734..13264ae8613 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -6,12 +6,15 @@ import type { ServerProvider, ServerProviderAuth, ServerProviderModel, + ServerProviderSlashCommand, ServerProviderState, } from "@t3tools/contracts"; import { ProviderDriverKind } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -61,6 +64,7 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ }); const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +const CURSOR_ACP_COMMAND_DISCOVERY_TIMEOUT_MS = 3_000; const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; const CURSOR_CLI_INSTALLATION_DOCS_URL = "https://cursor.com/docs/cli/installation"; const CURSOR_ACP_MODEL_DISCOVERY_FAILED_MESSAGE = [ @@ -439,6 +443,54 @@ const withCursorAcpProbeRuntime = ( Effect.scoped, ); +function parseCursorAvailableCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const byName = new Map(); + for (const command of commands) { + const name = command.name.trim(); + if (!name) { + continue; + } + const description = command.description.trim(); + const hint = command.input?.hint?.trim(); + byName.set(name.toLowerCase(), { + name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + }); + } + return [...byName.values()]; +} + +export const probeCursorProjectSlashCommands = Effect.fn("probeCursorProjectSlashCommands")( + function* (cursorSettings: CursorSettings, _cwd: string, environment?: NodeJS.ProcessEnv) { + return yield* withCursorAcpProbeRuntime( + cursorSettings, + (acp) => + Effect.gen(function* () { + const commands = yield* Deferred.make>(); + yield* acp.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "available_commands_update") { + return Effect.void; + } + return Deferred.succeed( + commands, + parseCursorAvailableCommands(update.availableCommands), + ).pipe(Effect.asVoid); + }); + yield* acp.start(); + return yield* Deferred.await(commands).pipe( + Effect.timeoutOption(Duration.millis(CURSOR_ACP_COMMAND_DISCOVERY_TIMEOUT_MS)), + Effect.map(Option.getOrElse(() => [])), + ); + }), + environment, + ); + }, +); + function normalizeCursorConfigOptionToken(value: string | null | undefined): string { return ( value diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index a8285e960fc..130a623eb6d 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -3,6 +3,7 @@ import { type ModelCapabilities, type OpenCodeSettings, type ServerProviderModel, + type ServerProviderSlashCommand, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; @@ -21,9 +22,10 @@ import { import { OpenCodeRuntime, openCodeRuntimeErrorDetail, + runOpenCodeSdk, type OpenCodeInventory, } from "../opencodeRuntime.ts"; -import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; +import type { Agent, Command, ProviderListResponse } from "@opencode-ai/sdk/v2"; const PROVIDER = ProviderDriverKind.make("opencode"); const OPENCODE_PRESENTATION = { @@ -252,6 +254,73 @@ function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray left.name.localeCompare(right.name)); } +function openCodeSlashCommandsFromCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const byName = new Map(); + for (const command of commands) { + const name = command.name.trim().replace(/^\/+/, ""); + if (!name) { + continue; + } + const description = command.description?.trim(); + const hint = command.hints + .map((entry) => entry.trim()) + .filter(Boolean) + .join(" "); + byName.set(name.toLowerCase(), { + name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + }); + } + return [...byName.values()]; +} + +export const listOpenCodeProjectSlashCommands = Effect.fn("listOpenCodeProjectSlashCommands")( + function* ( + openCodeSettings: OpenCodeSettings, + cwd: string, + environment?: NodeJS.ProcessEnv, + ): Effect.fn.Return< + ReadonlyArray, + OpenCodeProbeError, + OpenCodeRuntime + > { + if (!openCodeSettings.enabled) { + return []; + } + const openCodeRuntime = yield* OpenCodeRuntime; + const resolvedEnvironment = environment ?? process.env; + const isExternalServer = openCodeSettings.serverUrl.trim().length > 0; + + return yield* Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath: openCodeSettings.binaryPath, + serverUrl: openCodeSettings.serverUrl, + environment: resolvedEnvironment, + }); + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(isExternalServer && openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }); + const response = yield* runOpenCodeSdk("command.list", () => + client.command.list({ directory: cwd }), + ); + return openCodeSlashCommandsFromCommands(response.data ?? []); + }).pipe( + Effect.mapError( + (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + ), + ); + }, +); + export const makePendingOpenCodeProvider = ( openCodeSettings: OpenCodeSettings, ): Effect.Effect => diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index b3ab1145495..3e930da3a86 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -20,6 +20,7 @@ import { ProviderInstanceId, ServerSettings, type ServerProvider, + type ServerProviderSkill, type ServerProviderSlashCommand, type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; @@ -45,6 +46,7 @@ import { import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; +import { ProviderProjectCapabilitiesError } from "../Errors.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; import * as ProviderInstanceRegistry from "../Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "../Services/ProviderRegistry.ts"; @@ -101,6 +103,7 @@ type TestClaudeCapabilities = { readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; readonly slashCommands: ReadonlyArray; + readonly skills: ReadonlyArray; }; function claudeCapabilities(overrides: Partial = {}) { @@ -110,6 +113,7 @@ function claudeCapabilities(overrides: Partial = {}) { subscriptionType: undefined, tokenSource: undefined, slashCommands: [], + skills: [], ...overrides, }); } @@ -668,6 +672,139 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("only returns empty project capabilities for unavailable instances", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const enabledInstanceId = ProviderInstanceId.make("codex"); + const disabledInstanceId = ProviderInstanceId.make("codex_disabled"); + const unsupportedInstanceId = ProviderInstanceId.make("codex_unsupported"); + const provider = (instanceId: ProviderInstanceId, enabled: boolean): ServerProvider => ({ + instanceId, + driver: codexDriver, + status: enabled ? "ready" : "warning", + enabled, + installed: enabled, + auth: { status: enabled ? "authenticated" : "unknown" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: enabled ? "1.0.0" : null, + models: [], + slashCommands: [], + skills: [], + }); + const makeInstance = ( + instanceId: ProviderInstanceId, + enabled: boolean, + composerCapabilities?: ProviderInstance["composerCapabilities"], + ): ProviderInstance => { + const snapshot = provider(instanceId, enabled); + return { + instanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: `codex:instance:${instanceId}`, + }, + displayName: undefined, + enabled, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(snapshot), + refresh: Effect.succeed(snapshot), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + ...(composerCapabilities ? { composerCapabilities } : {}), + }; + }; + const instances = [ + makeInstance(enabledInstanceId, true, { + list: (input) => + Effect.fail( + new ProviderProjectCapabilitiesError({ + provider: codexDriver, + instanceId: enabledInstanceId, + cwd: input.cwd, + detail: "capability probe failed", + }), + ), + }), + makeInstance(disabledInstanceId, false, { + list: (input) => + Effect.fail( + new ProviderProjectCapabilitiesError({ + provider: codexDriver, + instanceId: disabledInstanceId, + cwd: input.cwd, + detail: "disabled probe should not run", + }), + ), + }), + makeInstance(unsupportedInstanceId, true), + ]; + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (instanceId) => + Effect.succeed(instances.find((instance) => instance.instanceId === instanceId)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistryLive.pipe( + Layer.provideMerge(instanceRegistryLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-project-capabilities-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const enabledExit = yield* Effect.exit( + registry.listProjectCapabilities({ + providerInstanceId: enabledInstanceId, + cwd: "/repo", + }), + ); + assert.strictEqual(Exit.isFailure(enabledExit), true); + + const disabled = yield* registry.listProjectCapabilities({ + providerInstanceId: disabledInstanceId, + cwd: "/repo", + }); + assert.deepStrictEqual(disabled, { + providerInstanceId: disabledInstanceId, + cwd: "/repo", + slashCommands: [], + skills: [], + }); + + const unsupported = yield* registry.listProjectCapabilities({ + providerInstanceId: unsupportedInstanceId, + cwd: "/repo", + }); + assert.deepStrictEqual(unsupported, { + providerInstanceId: unsupportedInstanceId, + cwd: "/repo", + slashCommands: [], + skills: [], + }); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it("persists merged provider snapshots for the providers that were refreshed", () => { const previousProviders = [ { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2df63e53830..c18b01c3c4b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -486,6 +486,35 @@ export const ProviderRegistryLive = Layer.effect( ); }); + const listProjectCapabilities: ProviderRegistryShape["listProjectCapabilities"] = (input) => + Effect.gen(function* () { + const empty = { + providerInstanceId: input.providerInstanceId, + cwd: input.cwd, + slashCommands: [], + skills: [], + }; + const instance = (yield* Ref.get(liveSubsRef)).get(input.providerInstanceId); + const providerSnapshot = (yield* Ref.get(providersRef)).find( + (provider) => provider.instanceId === input.providerInstanceId, + ); + if ( + !instance || + !instance.enabled || + providerSnapshot?.enabled === false || + providerSnapshot?.installed === false || + providerSnapshot?.availability === "unavailable" || + !instance.composerCapabilities + ) { + return empty; + } + const request = + input.forceReload === undefined + ? { cwd: input.cwd } + : { cwd: input.cwd, forceReload: input.forceReload }; + return yield* instance.composerCapabilities.list(request); + }); + /** * Diff the aggregator's live-source set against the current * `ProviderInstanceRegistry` and: @@ -688,6 +717,7 @@ export const ProviderRegistryLive = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + listProjectCapabilities, getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a..8cc9a527a04 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -25,13 +25,18 @@ import type { ProviderDriverKind, ProviderInstanceEnvironment, ProviderInstanceId, + ServerProviderProjectCapabilitiesResult, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; import type * as TextGeneration from "../textGeneration/TextGeneration.ts"; -import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; +import type { + ProviderAdapterError, + ProviderDriverError, + ProviderProjectCapabilitiesError, +} from "./Errors.ts"; import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; @@ -71,6 +76,12 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; + readonly composerCapabilities?: { + readonly list: (input: { + readonly cwd: string; + readonly forceReload?: boolean; + }) => Effect.Effect; + }; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b30338..0cf9029c477 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -10,12 +10,15 @@ import type { ProviderInstanceId, ProviderDriverKind, ServerProvider, + ServerProviderProjectCapabilitiesInput, + ServerProviderProjectCapabilitiesResult, ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import type { ProviderProjectCapabilitiesError } from "../Errors.ts"; export type ProviderMaintenanceActionKind = "update"; @@ -48,6 +51,16 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** + * Resolve provider commands and skills that depend on a project cwd. + * Unsupported or unavailable instances resolve to empty capability arrays. + * Live provider probe failures are surfaced so callers can retry or display + * a recoverable error instead of caching false empty results. + */ + readonly listProjectCapabilities: ( + input: ServerProviderProjectCapabilitiesInput, + ) => Effect.Effect; + /** * Resolve the maintenance capabilities owned by one live provider instance. * Falls back to manual-only capabilities when the instance is not live. diff --git a/apps/server/src/provider/projectCapabilities.ts b/apps/server/src/provider/projectCapabilities.ts new file mode 100644 index 00000000000..744968f3bdb --- /dev/null +++ b/apps/server/src/provider/projectCapabilities.ts @@ -0,0 +1,31 @@ +import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; + +import { ProviderProjectCapabilitiesError } from "./Errors.ts"; + +function describeCapabilityProbeFailure(cause: unknown): string { + if ( + cause && + typeof cause === "object" && + "message" in cause && + typeof cause.message === "string" && + cause.message.trim().length > 0 + ) { + return cause.message; + } + return "Provider project capability probe failed."; +} + +export function makeProviderProjectCapabilitiesError(input: { + readonly provider: ProviderDriverKind; + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + readonly cause: unknown; +}): ProviderProjectCapabilitiesError { + return new ProviderProjectCapabilitiesError({ + provider: input.provider, + instanceId: input.instanceId, + cwd: input.cwd, + detail: describeCapabilityProbeFailure(input.cause), + cause: input.cause, + }); +} diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index f33a9bbce42..5ce1ed78c9c 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -189,6 +189,13 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + listProjectCapabilities: (input) => + Effect.succeed({ + providerInstanceId: input.providerInstanceId, + cwd: input.cwd, + slashCommands: [], + skills: [], + }), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(lifecycleFor(provider)), setProviderMaintenanceActionState, diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 36598b05900..24ef7469c1b 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -11,6 +11,13 @@ export const makeProviderRegistryMock = ( getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), + listProjectCapabilities: (input) => + Effect.succeed({ + providerInstanceId: input.providerInstanceId, + cwd: input.cwd, + slashCommands: [], + skills: [], + }), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fafdbfa6814..4eca547d80e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -56,6 +56,7 @@ import { type TerminalError, type TerminalEvent, type TerminalMetadataStreamEvent, + ServerProviderProjectCapabilitiesError, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -305,6 +306,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.projectsReadFile, AuthOrchestrationReadScope], [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], + [WS_METHODS.providersProjectCapabilities, AuthOrchestrationReadScope], [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], @@ -1454,6 +1456,22 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.providersProjectCapabilities]: (input) => + observeRpcEffect( + WS_METHODS.providersProjectCapabilities, + providerRegistry.listProjectCapabilities(input).pipe( + Effect.mapError( + (cause) => + new ServerProviderProjectCapabilitiesError({ + providerInstanceId: input.providerInstanceId, + cwd: input.cwd, + cause, + detail: "Provider project capability probe failed.", + }), + ), + ), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.shellOpenInEditor]: (input) => observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { "rpc.aggregate": "workspace", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..f63430a8522 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -183,6 +183,7 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; +import { useProviderProjectCapabilities } from "../state/queries"; import { primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom, @@ -254,7 +255,6 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; -const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), @@ -2144,6 +2144,12 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const activeProviderProjectCapabilities = useProviderProjectCapabilities({ + environmentId, + providerInstanceId: activeProviderStatus?.instanceId ?? null, + cwd: gitCwd, + providers: providerStatuses, + }); const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -5094,7 +5100,7 @@ function ChatViewContent(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} - skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + skills={activeProviderProjectCapabilities.skills} anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..4e0fac4857e 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 { useProviderProjectCapabilities } from "../../state/queries"; import { type ElementContextDraft } from "../../lib/elementContext"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; @@ -771,16 +772,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); // Resolve the active instance's snapshot by `instanceId` so a custom - // instance gets its own slash commands, skills, and model list — not - // the first snapshot for the same driver kind. + // instance gets its own model list, not the first snapshot for the same + // driver kind. const selectedProviderEntry = useMemo( () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), [providerInstanceEntries, selectedInstanceId], ); - const selectedProviderStatus = useMemo( - () => selectedProviderEntry?.snapshot ?? null, - [selectedProviderEntry], - ); + const providerProjectCapabilities = useProviderProjectCapabilities({ + environmentId, + providerInstanceId: selectedInstanceId, + cwd: gitCwd, + providers: providerStatuses, + }); const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -972,7 +975,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) description: "Switch this thread back to normal build mode", }, ] satisfies ReadonlyArray>; - const providerSlashCommandItems = (selectedProviderStatus?.slashCommands ?? []).map( + const providerSlashCommandItems = providerProjectCapabilities.slashCommands.map( (command) => ({ id: `provider-slash-command:${selectedProvider}:${command.name}`, type: "provider-slash-command" as const, @@ -990,7 +993,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(providerProjectCapabilities.skills, composerTrigger.query).map( (skill) => ({ id: `skill:${selectedProvider}:${skill.name}`, type: "skill" as const, @@ -1005,7 +1008,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerTrigger, + providerProjectCapabilities.skills, + providerProjectCapabilities.slashCommands, + selectedProvider, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1070,15 +1079,28 @@ 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" || composerTriggerKind === "slash-command" + ? providerProjectCapabilities.isPending + : false; const composerMenuEmptyState = useMemo(() => { + if ( + providerProjectCapabilities.error && + (composerTriggerKind === "skill" || composerTriggerKind === "slash-command") + ) { + return providerProjectCapabilities.error; + } + if (workspaceEntries.error && composerTriggerKind === "path") { + return workspaceEntries.error; + } if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; } return composerTriggerKind === "path" ? "No matching files or folders." : "No matching command."; - }, [composerTriggerKind]); + }, [composerTriggerKind, providerProjectCapabilities.error, workspaceEntries.error]); // ------------------------------------------------------------------ // Provider traits UI @@ -2396,7 +2418,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={providerProjectCapabilities.skills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 79737e6109e..58c72a76c13 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -3,10 +3,15 @@ import { type CheckpointDiffTarget, type ComposerPathSearchTarget, } from "@t3tools/client-runtime/state/threads"; +import { buildProviderProjectCapabilitiesQueryTarget } from "@t3tools/client-runtime/state/provider-capabilities"; import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, OrchestrationThread, + ProviderInstanceId, + ServerProvider, + ServerProviderSlashCommand, + ServerProviderSkill, ThreadId, VcsListRefsResult, VcsRef, @@ -27,6 +32,8 @@ const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; const COMPOSER_PATH_SEARCH_LIMIT = 80; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; +const EMPTY_PROVIDER_SKILLS: ReadonlyArray = []; +const EMPTY_PROVIDER_SLASH_COMMANDS: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; export interface ThreadDetailView { @@ -214,6 +221,29 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { }; } +export function useProviderProjectCapabilities(target: { + readonly environmentId: EnvironmentId | null; + readonly providerInstanceId: ProviderInstanceId | null | undefined; + readonly cwd: string | null | undefined; + readonly providers?: ReadonlyArray | null; +}) { + const queryTarget = useMemo( + () => buildProviderProjectCapabilitiesQueryTarget(target), + [target.cwd, target.environmentId, target.providerInstanceId, target.providers], + ); + const result = useEnvironmentQuery( + queryTarget === null ? null : projectEnvironment.projectCapabilities(queryTarget), + ); + + return { + skills: result.data?.skills ?? EMPTY_PROVIDER_SKILLS, + slashCommands: result.data?.slashCommands ?? EMPTY_PROVIDER_SLASH_COMMANDS, + error: result.error, + isPending: result.isPending, + refresh: result.refresh, + }; +} + export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..16575173525 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -83,6 +83,10 @@ "types": "./src/state/projects.ts", "default": "./src/state/projects.ts" }, + "./state/provider-capabilities": { + "types": "./src/state/providerCapabilities.ts", + "default": "./src/state/providerCapabilities.ts" + }, "./state/project-grouping": { "types": "./src/state/projectGrouping.ts", "default": "./src/state/projectGrouping.ts" diff --git a/packages/client-runtime/src/state/projectCommands.ts b/packages/client-runtime/src/state/projectCommands.ts index 3defcc32154..fd6ecf6c39f 100644 --- a/packages/client-runtime/src/state/projectCommands.ts +++ b/packages/client-runtime/src/state/projectCommands.ts @@ -60,6 +60,12 @@ export function createProjectEnvironmentAtoms( tag: WS_METHODS.projectsSearchEntries, staleTimeMs: 15_000, }), + projectCapabilities: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:providers:project-capabilities", + tag: WS_METHODS.providersProjectCapabilities, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, + }), listEntries: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:projects:list-entries", tag: WS_METHODS.projectsListEntries, diff --git a/packages/client-runtime/src/state/providerCapabilities.test.ts b/packages/client-runtime/src/state/providerCapabilities.test.ts new file mode 100644 index 00000000000..0a268979f24 --- /dev/null +++ b/packages/client-runtime/src/state/providerCapabilities.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; + +import { buildProviderProjectCapabilitiesQueryTarget } from "./providerCapabilities.ts"; + +const environmentId = EnvironmentId.make("environment-a"); +const providerInstanceId = ProviderInstanceId.make("codex"); + +function provider( + overrides: Partial>, +): Pick { + return { + instanceId: providerInstanceId, + enabled: true, + installed: true, + ...overrides, + }; +} + +describe("buildProviderProjectCapabilitiesQueryTarget", () => { + it("builds a query for enabled available providers and preserves exact cwd", () => { + const cwd = "/repo/with-space "; + + expect( + buildProviderProjectCapabilitiesQueryTarget({ + environmentId, + providerInstanceId, + cwd, + providers: [provider({})], + }), + ).toEqual({ + environmentId, + input: { + providerInstanceId, + cwd, + }, + }); + }); + + it("does not query disabled, uninstalled, unavailable, missing, or blank targets", () => { + expect( + buildProviderProjectCapabilitiesQueryTarget({ + environmentId, + providerInstanceId, + cwd: "/repo", + providers: [provider({ enabled: false })], + }), + ).toBeNull(); + expect( + buildProviderProjectCapabilitiesQueryTarget({ + environmentId, + providerInstanceId, + cwd: "/repo", + providers: [provider({ installed: false })], + }), + ).toBeNull(); + expect( + buildProviderProjectCapabilitiesQueryTarget({ + environmentId, + providerInstanceId, + cwd: "/repo", + providers: [provider({ availability: "unavailable" })], + }), + ).toBeNull(); + expect( + buildProviderProjectCapabilitiesQueryTarget({ + environmentId, + providerInstanceId, + cwd: "/repo", + providers: [], + }), + ).toBeNull(); + expect( + buildProviderProjectCapabilitiesQueryTarget({ + environmentId, + providerInstanceId, + cwd: " ", + providers: [provider({})], + }), + ).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/providerCapabilities.ts b/packages/client-runtime/src/state/providerCapabilities.ts new file mode 100644 index 00000000000..3db3c10c763 --- /dev/null +++ b/packages/client-runtime/src/state/providerCapabilities.ts @@ -0,0 +1,58 @@ +import type { + EnvironmentId, + ProviderInstanceId, + ServerProvider, + ServerProviderProjectCapabilitiesInput, +} from "@t3tools/contracts"; + +export interface ProviderProjectCapabilitiesTarget { + readonly environmentId: EnvironmentId | null; + readonly providerInstanceId: ProviderInstanceId | null | undefined; + readonly cwd: string | null | undefined; + readonly providers?: ReadonlyArray< + Pick + > | null; +} + +export interface ProviderProjectCapabilitiesQueryTarget { + readonly environmentId: EnvironmentId; + readonly input: ServerProviderProjectCapabilitiesInput; +} + +function isProviderCapabilityQueryable( + provider: Pick, +): boolean { + return provider.enabled && provider.installed && provider.availability !== "unavailable"; +} + +export function buildProviderProjectCapabilitiesQueryTarget( + target: ProviderProjectCapabilitiesTarget, +): ProviderProjectCapabilitiesQueryTarget | null { + if ( + target.environmentId === null || + target.providerInstanceId === null || + target.providerInstanceId === undefined || + target.cwd === null || + target.cwd === undefined || + target.cwd.trim().length === 0 + ) { + return null; + } + + if (target.providers !== undefined) { + const provider = target.providers?.find( + (candidate) => candidate.instanceId === target.providerInstanceId, + ); + if (!provider || !isProviderCapabilityQueryable(provider)) { + return null; + } + } + + return { + environmentId: target.environmentId, + input: { + providerInstanceId: target.providerInstanceId, + cwd: target.cwd, + }, + }; +} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..b224e99b3fc 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -126,6 +126,9 @@ import { ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, ServerProcessResourceHistoryResult, + ServerProviderProjectCapabilitiesError, + ServerProviderProjectCapabilitiesInput, + ServerProviderProjectCapabilitiesResult, ServerSignalProcessInput, ServerSignalProcessResult, ServerUpsertKeybindingInput, @@ -154,6 +157,9 @@ export const WS_METHODS = { projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + // Provider project-scoped capability methods + providersProjectCapabilities: "providers.projectCapabilities", + // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -378,6 +384,12 @@ export const WsProjectsWriteFileRpc = Rpc.make(WS_METHODS.projectsWriteFile, { error: Schema.Union([ProjectWriteFileError, EnvironmentAuthorizationError]), }); +export const WsProvidersProjectCapabilitiesRpc = Rpc.make(WS_METHODS.providersProjectCapabilities, { + payload: ServerProviderProjectCapabilitiesInput, + success: ServerProviderProjectCapabilitiesResult, + error: Schema.Union([ServerProviderProjectCapabilitiesError, EnvironmentAuthorizationError]), +}); + export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { payload: LaunchEditorInput, error: Schema.Union([ExternalLauncherError, EnvironmentAuthorizationError]), @@ -703,6 +715,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsReadFileRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, + WsProvidersProjectCapabilitiesRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, WsAssetsCreateUrlRpc, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index c906f86f4dc..1b75fdc7fec 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,9 +1,23 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { ServerProvider } from "./server.ts"; +import { + ServerProvider, + ServerProviderProjectCapabilitiesError, + ServerProviderProjectCapabilitiesInput, + ServerProviderProjectCapabilitiesResult, +} from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); +const decodeProjectCapabilitiesInput = Schema.decodeUnknownSync( + ServerProviderProjectCapabilitiesInput, +); +const decodeProjectCapabilitiesResult = Schema.decodeUnknownSync( + ServerProviderProjectCapabilitiesResult, +); +const decodeProjectCapabilitiesError = Schema.decodeUnknownSync( + ServerProviderProjectCapabilitiesError, +); describe("ServerProvider", () => { it("defaults capability arrays when decoding provider snapshots", () => { @@ -71,4 +85,31 @@ describe("ServerProvider", () => { expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); }); + + it("preserves exact project capability cwd values", () => { + const input = decodeProjectCapabilitiesInput({ + providerInstanceId: "codex", + cwd: "/repo/with-space ", + }); + const result = decodeProjectCapabilitiesResult({ + providerInstanceId: "codex", + cwd: " /repo/with-leading-space", + }); + const error = decodeProjectCapabilitiesError({ + _tag: "ServerProviderProjectCapabilitiesError", + providerInstanceId: "codex", + cwd: "/repo/with-space ", + message: "Failed to load project capabilities.", + }); + + expect(input.cwd).toBe("/repo/with-space "); + expect(result.cwd).toBe(" /repo/with-leading-space"); + expect(error.cwd).toBe("/repo/with-space "); + expect(() => + decodeProjectCapabilitiesInput({ + providerInstanceId: "codex", + cwd: " ", + }), + ).toThrow(); + }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index b76ea965afe..4654e79fd72 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -21,6 +21,8 @@ import { ModelCapabilities } from "./model.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ServerSettings } from "./settings.ts"; +const ExactNonBlankString = Schema.String.check(Schema.isPattern(/\S/u)); + const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), message: TrimmedNonEmptyString, @@ -91,6 +93,52 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderProjectCapabilitiesInput = Schema.Struct({ + providerInstanceId: ProviderInstanceId, + cwd: ExactNonBlankString, + forceReload: Schema.optionalKey(Schema.Boolean), +}); +export type ServerProviderProjectCapabilitiesInput = + typeof ServerProviderProjectCapabilitiesInput.Type; + +export const ServerProviderProjectCapabilitiesResult = Schema.Struct({ + providerInstanceId: ProviderInstanceId, + cwd: ExactNonBlankString, + slashCommands: Schema.Array(ServerProviderSlashCommand).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), +}); +export type ServerProviderProjectCapabilitiesResult = + typeof ServerProviderProjectCapabilitiesResult.Type; + +export class ServerProviderProjectCapabilitiesError extends Schema.TaggedErrorClass()( + "ServerProviderProjectCapabilitiesError", + { + providerInstanceId: ProviderInstanceId, + cwd: ExactNonBlankString, + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { + readonly providerInstanceId: ProviderInstanceId; + readonly cwd: string; + readonly cause?: unknown; + readonly detail?: string; + }) { + super({ + providerInstanceId: props.providerInstanceId, + cwd: props.cwd, + cause: props.cause, + message: + props.detail?.trim() || + `Failed to load project capabilities for provider '${props.providerInstanceId}'.`, + } as any); + } +} + /** * Availability of a configured provider instance from the runtime's POV. *