diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index b76d56d45dbc..0b428aa6508b 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -81,6 +81,9 @@ Review changed TypeScript and directly affected call sites for the conventions b ## Change discipline +- Keep provider capability discovery provider-owned. Project/worktree catalogs must be cached by provider instance plus cwd, preserve the machine snapshot as the pre-session fallback, and never overwrite one project's menus with another cwd's snapshot. Flag generic filesystem reimplementations of provider discovery when the driver can probe from the active session cwd. +- Keep cwd catalog refreshes narrow: query only skills and slash commands needed by the menu. Do not reuse full provider health/model inventory probes for a post-session catalog refresh, because unrelated model/auth commands can block the catalog indefinitely. +- Treat provider-instance rebuilds as cache-generation changes: clear and publish volatile cwd catalogs, reject results from stale instance objects, and attach scoped data to the current machine snapshot inside one atomic `Ref.modify` so concurrent status refreshes cannot be rolled back. - Preserve useful comments, invariants, and specification documentation while moving code. - Do not add large tests solely to prove a mechanical refactor. Update existing tests and imports as needed. - If backend behavior changes, require focused tests. Use test implementations/layers for external services only; do not mock out core business logic. diff --git a/.macroscope/check-run-agents/ui-consistency.md b/.macroscope/check-run-agents/ui-consistency.md index c2c091b205cf..1a6a5c5b2673 100644 --- a/.macroscope/check-run-agents/ui-consistency.md +++ b/.macroscope/check-run-agents/ui-consistency.md @@ -72,6 +72,7 @@ The goal is not to minimize CSS or class counts at any cost. The goal is to put ## Change discipline +- When a provider skill catalog becomes workspace-scoped, trace every renderer that resolves `$skill` tokens, including composer menus/editors and sent-message timelines on web and mobile. The same token must not render as a chip before send and raw text afterward. - Review the pull request's changed scope and directly affected consumers. Do not turn a focused PR into a demand for unrelated legacy cleanup. - Prefer the smallest durable contract over a component-specific workaround or a broad abstraction with one consumer. - Preserve intentional exceptions and comments that explain browser, virtualizer, theme, or Electron constraints. diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c771aaebcb6e..45b1457a911b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -64,6 +64,10 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; +import { + resolveProviderSkillsForCwd, + resolveProviderSlashCommandsForCwd, +} from "@t3tools/client-runtime/providerSkills"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; @@ -355,6 +359,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ) ?? null ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); + const workspaceSnapshotCwd = props.selectedThread.worktreePath ?? props.projectCwd; + const selectedProviderSkills = selectedProviderStatus + ? resolveProviderSkillsForCwd(selectedProviderStatus, workspaceSnapshotCwd) + : []; + const selectedProviderSlashCommands = selectedProviderStatus + ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, workspaceSnapshotCwd) + : []; // ── Trigger detection ──────────────────────────────────── const [composerSelection, setComposerSelection] = useState(() => ({ @@ -420,7 +431,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 selectedProviderSlashCommands) { if (!cmd.name.toLowerCase().includes(q)) continue; providerCommands.push({ id: `pcmd:${cmd.name}`, @@ -431,7 +442,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); } - const skillItems = (selectedProviderStatus?.skills ?? []) + const skillItems = selectedProviderSkills .filter((skill) => matchesSlashSkillQuery(skill, q)) .map((skill) => ({ id: `skill:${skill.name}`, @@ -445,7 +456,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } if (composerTrigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); + const enabledSkills = selectedProviderSkills.filter((s) => s.enabled); const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { trimLeadingPattern: /^\$+/, }); @@ -542,7 +553,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); + }, [composerTrigger, pathSearch.entries, selectedProviderSkills, selectedProviderSlashCommands]); // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; @@ -797,7 +808,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ref={inputRef} multiline value={props.draftMessage} - skills={selectedProviderStatus?.skills ?? []} + skills={selectedProviderSkills} 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 2c6860199722..756ecec6f48a 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1,4 +1,5 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import type { LegendListRef } from "@legendapp/list/react-native"; @@ -447,12 +448,22 @@ 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 selectedProviderSkills = useMemo(() => { + const provider = props.serverConfig?.providers.find( + (candidate) => candidate.instanceId === selectedInstanceId, + ); + return provider + ? resolveProviderSkillsForCwd( + provider, + props.selectedThread.worktreePath ?? props.projectWorkspaceRoot, + ) + : []; + }, [ + props.projectWorkspaceRoot, + props.selectedThread.worktreePath, + props.serverConfig, + selectedInstanceId, + ]); useLayoutEffect(() => { selectedThreadKeyRef.current = selectedThreadKey; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 8766e8cb76f6..9127fabb8943 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -306,6 +306,7 @@ describe("ProviderCommandReactor", () => { : {}), }, ]; + const refreshWorkspaceSnapshot = vi.fn(() => Effect.succeed(providerSnapshots as never)); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -392,7 +393,9 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(reactorOrchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), - Layer.provideMerge(makeProviderRegistryLayer(providerSnapshots as never)), + Layer.provideMerge( + makeProviderRegistryLayer(providerSnapshots as never, { refreshWorkspaceSnapshot }), + ), Layer.provideMerge( Layer.mock(GitWorkflowService.GitWorkflowService)({ renameBranch, @@ -501,6 +504,7 @@ describe("ProviderCommandReactor", () => { stopSession, renameBranch, refreshStatus, + refreshWorkspaceSnapshot, generateBranchName, generateThreadTitle, runtimeSessions, @@ -536,6 +540,7 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await waitFor(() => harness.refreshWorkspaceSnapshot.mock.calls.length === 1); expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.make("thread-1")); expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ cwd: "/tmp/provider-project", @@ -545,6 +550,10 @@ describe("ProviderCommandReactor", () => { }, runtimeMode: "approval-required", }); + expect(harness.refreshWorkspaceSnapshot).toHaveBeenCalledWith({ + instanceId: ProviderInstanceId.make("codex"), + cwd: "/tmp/provider-project", + }); const readModel = await harness.readModel(); const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); @@ -1906,6 +1915,7 @@ describe("ProviderCommandReactor", () => { ); await waitFor(() => harness.sendTurn.mock.calls.length === 2); + await waitFor(() => harness.refreshWorkspaceSnapshot.mock.calls.length === 2); expect(harness.startSession.mock.calls.length).toBe(1); expect(harness.stopSession.mock.calls.length).toBe(0); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cfc95f2613fb..120765f12be8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -604,21 +604,31 @@ const make = Effect.gen(function* () { thread, projects: project ? [project] : [], }); + const refreshWorkspaceSnapshot = effectiveCwd + ? providerRegistry + .refreshWorkspaceSnapshot({ + instanceId: desiredInstanceId, + cwd: effectiveCwd, + }) + .pipe(Effect.forkDetach) + : Effect.void; const startProviderSession = (input?: { readonly resumeCursor?: unknown; readonly provider?: ProviderDriverKind; }) => - providerService.startSession(threadId, { - threadId, - ...(preferredProvider ? { provider: preferredProvider } : {}), - providerInstanceId: desiredInstanceId, - ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - ...(thread.title ? { title: thread.title } : {}), - modelSelection: desiredModelSelection, - ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), - runtimeMode: desiredRuntimeMode, - }); + providerService + .startSession(threadId, { + threadId, + ...(preferredProvider ? { provider: preferredProvider } : {}), + providerInstanceId: desiredInstanceId, + ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + ...(thread.title ? { title: thread.title } : {}), + modelSelection: desiredModelSelection, + ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + runtimeMode: desiredRuntimeMode, + }) + .pipe(Effect.tap(() => refreshWorkspaceSnapshot)); const bindSessionToThread = (session: ProviderSession) => Effect.gen(function* () { @@ -676,6 +686,7 @@ const make = Effect.gen(function* () { !shouldRestartForModelChange && !shouldRestartForModelSelectionChange ) { + yield* refreshWorkspaceSnapshot; return existingSessionThreadId; } diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index e099d52e5189..9f43b85d68ca 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -33,6 +33,7 @@ import { checkClaudeProviderStatus, makePendingClaudeProvider, probeClaudeCapabilities, + probeClaudeWorkspaceCatalog, } from "../Layers/ClaudeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -174,7 +175,6 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), ); - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); const snapshot = yield* makeManagedServerProvider>({ maintenanceCapabilities, @@ -202,6 +202,25 @@ export const ClaudeDriver: ProviderDriver = { }), ), ); + const snapshotForCwd = (workspaceCwd: string) => + Effect.all([ + snapshot.getSnapshot, + probeClaudeWorkspaceCatalog(effectiveConfig, processEnv, workspaceCwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ]).pipe( + Effect.map(([machineSnapshot, catalog]) => ({ ...machineSnapshot, ...catalog })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe Claude catalog for '${workspaceCwd}'`, + cause, + }), + ), + ); return { instanceId, @@ -214,6 +233,7 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 15d7a1ff0216..b59259c64d03 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -36,7 +36,12 @@ 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, + makePendingCodexProvider, + probeCodexSkillsForCwd, +} from "../Layers/CodexProvider.ts"; +import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; @@ -197,6 +202,32 @@ export const CodexDriver: ProviderDriver = { }), ), ); + const snapshotForCwd = (cwd: string) => + Effect.all([ + snapshot.getSnapshot, + probeCodexSkillsForCwd({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd, + environment: processEnv, + }).pipe( + Effect.scoped, + Effect.timeout("20 seconds"), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe Codex skills for '${cwd}'`, + cause, + }), + ), + ); return { instanceId, @@ -206,6 +237,7 @@ export const CodexDriver: ProviderDriver = { accentColor, enabled, snapshot, + snapshotForCwd, adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index a01e414f8116..2402b2f59bae 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -30,6 +30,7 @@ import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; import { checkOpenCodeProviderStatus, makePendingOpenCodeProvider, + openCodeSkillsToServerProviderSkills, } from "../Layers/OpenCodeProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -178,6 +179,31 @@ export const OpenCodeDriver: ProviderDriver }), ), ); + const loadSkillsForCwd = (cwd: string) => + effectiveConfig.serverUrl.trim().length > 0 + ? Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath: effectiveConfig.binaryPath, + serverUrl: effectiveConfig.serverUrl, + environment: processEnv, + }); + return yield* openCodeRuntime.loadOpenCodeSkills( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: cwd, + ...(effectiveConfig.serverPassword + ? { serverPassword: effectiveConfig.serverPassword } + : {}), + }), + ); + }), + ) + : openCodeRuntime.loadSkillsFromCli({ + binaryPath: effectiveConfig.binaryPath, + cwd, + environment: processEnv, + }); return { instanceId, @@ -187,6 +213,25 @@ export const OpenCodeDriver: ProviderDriver accentColor, enabled, snapshot, + snapshotForCwd: (cwd) => + Effect.all([ + snapshot.getSnapshot, + loadSkillsForCwd(cwd).pipe(Effect.timeout("20 seconds")), + ]).pipe( + Effect.map(([machineSnapshot, skills]) => ({ + ...machineSnapshot, + skills: openCodeSkillsToServerProviderSkills(skills), + })), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to probe OpenCode skills for '${cwd}'`, + cause, + }), + ), + ), adapter, textGeneration, } satisfies ProviderInstance; diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 806f7e19b905..d192aabd84ce 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -631,7 +631,7 @@ function nonEmptyProbeString(value: string): string | undefined { return candidate ? candidate : undefined; } -type ClaudeCapabilitiesProbe = { +export type ClaudeCapabilitiesProbe = { readonly email: string | undefined; readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; @@ -729,7 +729,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { * This is used as a fallback when `claude auth status` does not include * subscription type information. */ -const probeClaudeCapabilities = ( +const probeClaudeCapabilitiesStrict = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, cwd?: string, @@ -779,6 +779,15 @@ const probeClaudeCapabilities = ( if (!abort.signal.aborted) abort.abort(); }), ), + ); +}; + +const probeClaudeCapabilities = ( + claudeSettings: ClaudeSettings, + environment?: NodeJS.ProcessEnv, + cwd?: string, +) => + probeClaudeCapabilitiesStrict(claudeSettings, environment, cwd).pipe( Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), Effect.result, Effect.map((result) => { @@ -786,7 +795,26 @@ const probeClaudeCapabilities = ( return Option.isSome(result.success) ? result.success.value : undefined; }), ); -}; + +export const probeClaudeWorkspaceCatalog = Effect.fn("probeClaudeWorkspaceCatalog")(function* ( + claudeSettings: ClaudeSettings, + environment: NodeJS.ProcessEnv | undefined, + cwd: string, +) { + const [capabilities, skills] = yield* Effect.all( + [ + probeClaudeCapabilitiesStrict(claudeSettings, environment, cwd).pipe( + Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS), + ), + discoverClaudeSkills(claudeSettings, cwd, environment ?? process.env), + ], + { concurrency: "unbounded" }, + ); + return { + skills, + slashCommands: dedupeSlashCommands(capabilities.slashCommands), + }; +}); const runClaudeCommand = Effect.fn("runClaudeCommand")(function* ( claudeSettings: ClaudeSettings, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 93730046dc49..82bcc1b09c90 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -421,6 +421,53 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; }); +export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: 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, + codexAppServerArgs(input.launchArgs), + { 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 skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); + return parseCodexSkillsListResponse(skillsResponse, input.cwd); +}); + const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { const models = new Set(); for (const model of codexSettings.customModels) { @@ -521,6 +568,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > = probeCodexAppServerProvider, environment?: NodeJS.ProcessEnv, + cwd: string = process.cwd(), ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, @@ -551,7 +599,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), - cwd: process.cwd(), + cwd, customModels: codexSettings.customModels, environment: resolvedEnvironment, }).pipe( diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..a3d449ea226f 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -221,6 +221,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + loadOpenCodeSkills: () => Effect.succeed([]), loadInventoryFromCli: () => Effect.fail( new OpenCodeRuntimeError({ @@ -229,6 +230,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { cause: null, }), ), + loadSkillsFromCli: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadSkillsFromCli", + detail: "OpenCodeRuntimeTestDouble.loadSkillsFromCli not used in this test", + cause: null, + }), + ), }; const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 93f4b97995dc..fba707794575 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -99,6 +99,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }), ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), + loadOpenCodeSkills: () => + Effect.succeed((runtimeMock.state.inventory as OpenCodeInventory).skills ?? []), loadInventoryFromCli: ({ cwd }) => { runtimeMock.state.inventoryCwd = cwd; return runtimeMock.state.inventoryError @@ -111,6 +113,8 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ) : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory); }, + loadSkillsFromCli: () => + Effect.succeed((runtimeMock.state.inventory as OpenCodeInventory).skills), }; beforeEach(() => { diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 62f29c47eb38..909320c03515 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -256,9 +256,11 @@ function trimOptional(value: string | null | undefined): string | undefined { return trimmed && trimmed.length > 0 ? trimmed : undefined; } -function flattenOpenCodeSkills(input: OpenCodeInventory): ReadonlyArray { +export function openCodeSkillsToServerProviderSkills( + input: OpenCodeInventory["skills"] | undefined, +): ReadonlyArray { const skills: ServerProviderSkill[] = []; - for (const skill of input.skills ?? []) { + for (const skill of input ?? []) { const name = trimOptional(skill.name); const path = trimOptional(skill.location); if (!name || !path) { @@ -457,7 +459,7 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); - const skills = flattenOpenCodeSkills(inventoryExit.value); + const skills = openCodeSkillsToServerProviderSkills(inventoryExit.value.skills); const connectedCount = inventoryExit.value.providerList.connected.length; return buildServerProvider({ presentation: OPENCODE_PRESENTATION, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index f7ae95d8a927..96ef24e826e3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -41,6 +41,7 @@ import { haveProvidersChanged, mergeProviderSnapshot, ProviderRegistryLive, + upsertProviderWorkspaceSnapshot, } from "./ProviderRegistry.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; @@ -406,6 +407,24 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("probes Codex snapshots from the requested workspace cwd", () => + Effect.gen(function* () { + let observedCwd: string | undefined; + const status = yield* checkCodexProviderStatus( + defaultCodexSettings, + (input) => { + observedCwd = input.cwd; + return Effect.succeed(makeCodexProbeSnapshot()); + }, + undefined, + "/workspace/project-a", + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(observedCwd, "/workspace/project-a"); + }), + ); + it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => @@ -529,6 +548,41 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); describe("ProviderRegistryLive", () => { + it("keeps the machine snapshot while caching a provider-probed cwd snapshot", () => { + const machine = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + } satisfies ServerProvider; + const scoped = { + ...machine, + checkedAt: "2026-01-01T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], + } satisfies ServerProvider; + + const next = upsertProviderWorkspaceSnapshot(machine, "/workspace", scoped); + + assert.deepEqual(next.skills, machine.skills); + assert.deepEqual(next.slashCommands, machine.slashCommands); + assert.deepEqual(next.workspaceSnapshots, [ + { + cwd: "/workspace", + checkedAt: scoped.checkedAt, + slashCommands: scoped.slashCommands, + skills: scoped.skills, + }, + ]); + }); + it("treats equal provider snapshots as unchanged", () => { const providers = [ { @@ -895,6 +949,132 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }), ); + it.effect("publishes cwd snapshots and clears them when the provider instance rebuilds", () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const machineProvider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const scopedProvider = { + ...machineProvider, + checkedAt: "2026-06-10T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/workspace/SKILL.md", enabled: true }], + } as const satisfies ServerProvider; + const snapshotCalls = yield* Ref.make(0); + const makeInstance = ( + provider: ServerProvider, + snapshotForCwd: NonNullable, + ): ProviderInstance => ({ + instanceId, + driverKind: driver, + continuationIdentity: { + driverKind: driver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + }, + snapshotForCwd, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }); + const firstInstance = makeInstance(machineProvider, () => + Ref.update(snapshotCalls, (count) => count + 1).pipe(Effect.as(scopedProvider)), + ); + const rebuiltProvider = { + ...machineProvider, + checkedAt: "2026-06-10T00:02:00.000Z", + } satisfies ServerProvider; + const rebuiltInstance = makeInstance(rebuiltProvider, () => + Effect.succeed(scopedProvider), + ); + const registryChanges = yield* PubSub.unbounded(); + const instancesRef = yield* Ref.make>([firstInstance]); + const instanceRegistryLayer = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Ref.get(instancesRef).pipe( + Effect.map((instances) => + instances.find((instance) => instance.instanceId === requestedId), + ), + ), + listInstances: Ref.get(instancesRef), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.fromPubSub(registryChanges), + subscribeChanges: PubSub.subscribe(registryChanges), + }, + ); + 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-workspace-snapshot-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const workspaceUpdate = yield* registry.streamChanges.pipe( + Stream.runHead, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + const published = yield* Fiber.join(workspaceUpdate); + assert.strictEqual(published._tag, "Some"); + const providers = yield* registry.getProviders; + assert.deepStrictEqual(providers[0]?.skills, machineProvider.skills); + assert.deepStrictEqual( + providers[0]?.workspaceSnapshots?.[0]?.skills, + scopedProvider.skills, + ); + yield* registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + assert.strictEqual(yield* Ref.get(snapshotCalls), 1); + + yield* Ref.set(instancesRef, [rebuiltInstance]); + yield* PubSub.publish(registryChanges, undefined); + let rebuilt = yield* registry.getProviders; + for ( + let attempt = 0; + attempt < 50 && rebuilt[0]?.checkedAt !== rebuiltProvider.checkedAt; + attempt += 1 + ) { + yield* Effect.yieldNow; + rebuilt = yield* registry.getProviders; + } + assert.strictEqual(rebuilt[0]?.checkedAt, rebuiltProvider.checkedAt); + assert.strictEqual(rebuilt[0]?.workspaceSnapshots, undefined); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("persists the merged snapshot when a live update has empty models", () => Effect.gen(function* () { const cursorDriver = ProviderDriverKind.make("cursor"); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 760c8e1c59e8..ccc81a42803e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -78,6 +78,26 @@ const makeManualProviderMaintenanceCapabilities = (provider: ProviderDriverKind) const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean => (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +const MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER = 16; + +export function upsertProviderWorkspaceSnapshot( + provider: ServerProvider, + cwd: string, + scopedSnapshot: ServerProvider, +): ServerProvider { + const workspaceSnapshot = { + cwd, + checkedAt: scopedSnapshot.checkedAt, + slashCommands: scopedSnapshot.slashCommands, + skills: scopedSnapshot.skills, + } satisfies NonNullable[number]; + const workspaceSnapshots = [ + ...(provider.workspaceSnapshots ?? []).filter((snapshot) => snapshot.cwd !== cwd), + workspaceSnapshot, + ].slice(-MAX_WORKSPACE_SNAPSHOTS_PER_PROVIDER); + return { ...provider, workspaceSnapshots }; +} + const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { if (provider.driver !== ProviderDriverKind.make("opencode")) { return true; @@ -132,6 +152,11 @@ export const mergeProviderSnapshot = ( : { ...nextProvider, models: mergeProviderModels(nextProvider, previousProvider.models, nextProvider.models), + ...(nextProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: nextProvider.workspaceSnapshots } + : previousProvider.workspaceSnapshots !== undefined + ? { workspaceSnapshots: previousProvider.workspaceSnapshots } + : {}), }; export const mergeProviderSnapshots = ( @@ -321,7 +346,8 @@ export const ProviderRegistryLive = Layer.effect( cacheDir: config.providerStatusCacheDir, instanceId: key, }).pipe(Effect.provideService(Path.Path, path)); - yield* writeProviderStatusCache({ filePath, provider }).pipe( + const { workspaceSnapshots: _workspaceSnapshots, ...machineProvider } = provider; + yield* writeProviderStatusCache({ filePath, provider: machineProvider }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.tapError(Effect.logError), @@ -563,6 +589,28 @@ export const ProviderRegistryLive = Layer.effect( newlyAdded.push([instanceId, instance] as const); } + const rebuiltInstanceIds = new Set( + newlyAdded + .map(([instanceId]) => instanceId) + .filter((instanceId) => previousSubs.has(instanceId)), + ); + if (rebuiltInstanceIds.size > 0) { + const [previousProviders, providers] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const providers = previousProviders.map((provider) => { + if (!rebuiltInstanceIds.has(provider.instanceId)) return provider; + const { workspaceSnapshots: _workspaceSnapshots, ...machineSnapshot } = provider; + return machineSnapshot; + }); + return [[previousProviders, providers] as const, providers]; + }, + ); + if (haveProvidersChanged(previousProviders, providers)) { + yield* PubSub.publish(changesPubSub, providers); + } + } + // Fork long-lived subscriptions to each new/rebuilt instance's // change stream before reading its current snapshot. If the // driver's own initial probe finishes during this sync, either @@ -704,12 +752,60 @@ export const ProviderRegistryLive = Layer.effect( return yield* Ref.get(providersRef); }); + const refreshWorkspaceSnapshot = Effect.fn("refreshWorkspaceSnapshot")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) { + const providers = yield* Ref.get(providersRef); + const machineSnapshot = providers.find( + (provider) => provider.instanceId === input.instanceId, + ); + if ( + machineSnapshot === undefined || + machineSnapshot.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd) + ) { + return providers; + } + const instance = yield* instanceRegistry.getInstance(input.instanceId); + if (instance?.snapshotForCwd === undefined) { + return providers; + } + const scopedSnapshot = yield* instance.snapshotForCwd(input.cwd); + if (scopedSnapshot.status === "error") { + return yield* Ref.get(providersRef); + } + const source = buildSnapshotSource(instance); + const correlatedSnapshot = yield* correlateSnapshotWithSource(source, scopedSnapshot); + const currentInstance = yield* instanceRegistry.getInstance(input.instanceId); + if (currentInstance !== instance) { + return yield* Ref.get(providersRef); + } + const [previousProviders, nextProviders] = yield* Ref.modify( + providersRef, + (previousProviders) => { + const nextProviders = previousProviders.map((provider) => + provider.instanceId === input.instanceId && + !provider.workspaceSnapshots?.some((snapshot) => snapshot.cwd === input.cwd) + ? upsertProviderWorkspaceSnapshot(provider, input.cwd, correlatedSnapshot) + : provider, + ); + return [[previousProviders, nextProviders] as const, nextProviders]; + }, + ); + if (haveProvidersChanged(previousProviders, nextProviders)) { + yield* PubSub.publish(changesPubSub, nextProviders); + } + return nextProviders; + }); + return { getProviders: Ref.get(providersRef), refresh: (provider?: ProviderDriverKind) => refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshWorkspaceSnapshot: (input) => + refreshWorkspaceSnapshot(input).pipe(Effect.catchCause(recoverRefreshFailure)), getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a4..bdb3020ed42f 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -25,6 +25,7 @@ import type { ProviderDriverKind, ProviderInstanceEnvironment, ProviderInstanceId, + ServerProvider, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; @@ -69,6 +70,7 @@ export interface ProviderInstance { readonly accentColor?: string | undefined; readonly enabled: boolean; readonly snapshot: ServerProviderShape; + readonly snapshotForCwd?: (cwd: string) => Effect.Effect; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; } diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b303381..a1ea0c083ac2 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -48,6 +48,11 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + readonly refreshWorkspaceSnapshot: (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) => 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/opencodeRuntime.inventory.test.ts b/apps/server/src/provider/opencodeRuntime.inventory.test.ts index 8b22a52a2060..34d5d0b3b6c5 100644 --- a/apps/server/src/provider/opencodeRuntime.inventory.test.ts +++ b/apps/server/src/provider/opencodeRuntime.inventory.test.ts @@ -38,4 +38,19 @@ it.layer(testLayer)("loadOpenCodeInventory", (it) => { NodeAssert.deepEqual(inventory.skills, []); }), ); + + it.effect("keeps direct skill discovery failures visible", () => + Effect.gen(function* () { + const runtime = yield* OpenCodeRuntime; + const client = { + app: { + skills: () => Promise.reject(new Error("skills endpoint unavailable")), + }, + } as unknown as OpencodeClient; + + const error = yield* runtime.loadOpenCodeSkills(client).pipe(Effect.flip); + + NodeAssert.equal(error.operation, "app.skills"); + }), + ); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 2ff4fa1292f2..9b21602cb9ab 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -178,11 +178,19 @@ export interface OpenCodeRuntimeShape { readonly loadOpenCodeInventory: ( client: OpencodeClient, ) => Effect.Effect; + readonly loadOpenCodeSkills: ( + client: OpencodeClient, + ) => Effect.Effect, OpenCodeRuntimeError>; readonly loadInventoryFromCli: (input: { readonly binaryPath: string; readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect; + readonly loadSkillsFromCli: (input: { + readonly binaryPath: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; + }) => Effect.Effect, OpenCodeRuntimeError>; } function parseServerUrlFromOutput(output: string): string | null { @@ -700,11 +708,12 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.map((result) => result.data ?? []), ); - const loadSkills = (client: OpencodeClient) => + const loadOpenCodeSkills: OpenCodeRuntimeShape["loadOpenCodeSkills"] = (client) => runOpenCodeSdk("app.skills", () => client.app.skills()).pipe( Effect.map((result) => (result.data ?? []) as ReadonlyArray), - Effect.orElseSucceed((): ReadonlyArray => []), ); + const loadSkills = (client: OpencodeClient) => + loadOpenCodeSkills(client).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => Effect.all([loadProviders(client), loadAgents(client), loadSkills(client)], { @@ -809,13 +818,34 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }; }); + const loadSkillsFromCli: OpenCodeRuntimeShape["loadSkillsFromCli"] = (input) => + runOpenCodeCommand({ + binaryPath: input.binaryPath, + args: ["debug", "skill"], + cwd: input.cwd, + ...(input.environment !== undefined ? { environment: input.environment } : {}), + }).pipe( + Effect.flatMap((result) => + result.code === 0 + ? Effect.succeed(parseSkillsCliOutput(result.stdout)) + : Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadSkillsFromCli", + detail: `OpenCode skills command exited with code ${result.code}.`, + }), + ), + ), + ); + return { startOpenCodeServerProcess, connectToOpenCodeServer, runOpenCodeCommand, createOpenCodeSdkClient, loadOpenCodeInventory, + loadOpenCodeSkills, loadInventoryFromCli, + loadSkillsFromCli, } satisfies OpenCodeRuntimeShape; }); diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 641c9b52e56c..c45f1f7406cb 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -189,6 +189,7 @@ function makeRegistry( getProviders: Ref.get(providersRef), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), + refreshWorkspaceSnapshot: () => Ref.get(providersRef), 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 36598b059009..4ca947d607cf 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -7,15 +7,20 @@ import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMainte export const makeProviderRegistryMock = ( providers: ReadonlyArray = [], + overrides: Partial = {}, ): ProviderRegistryShape => ({ getProviders: Effect.succeed(providers), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), + refreshWorkspaceSnapshot: () => Effect.succeed(providers), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => Effect.succeed(makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null })), setProviderMaintenanceActionState: () => Effect.succeed(providers), streamChanges: Stream.empty, + ...overrides, }); -export const makeProviderRegistryLayer = (providers: ReadonlyArray = []) => - Layer.succeed(ProviderRegistry, makeProviderRegistryMock(providers)); +export const makeProviderRegistryLayer = ( + providers: ReadonlyArray = [], + overrides: Partial = {}, +) => Layer.succeed(ProviderRegistry, makeProviderRegistryMock(providers, overrides)); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index 1fcf9bc4c73a..b47a98c2d95b 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -107,6 +107,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + loadOpenCodeSkills: () => Effect.succeed([]), loadInventoryFromCli: () => Effect.fail( new OpenCodeRuntime.OpenCodeRuntimeError({ @@ -115,6 +116,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { cause: null, }), ), + loadSkillsFromCli: () => + Effect.fail( + new OpenCodeRuntime.OpenCodeRuntimeError({ + operation: "loadSkillsFromCli", + detail: "OpenCodeRuntimeTestDouble.loadSkillsFromCli not used in this test", + cause: null, + }), + ), }; const DEFAULT_TEST_MODEL_SELECTION = { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 46ed051154a6..09ca8db1f537 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,6 +27,7 @@ import { type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; +import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; import { changeRequestAutoSettles, effectiveSettled, @@ -6681,7 +6682,11 @@ function ChatViewContent(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} - skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + skills={ + activeProviderStatus + ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) + : EMPTY_PROVIDER_SKILLS + } anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} contentInsetEndAdjustment={composerOverlayHeight} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f29d6c2b4f6a..7820c1b09632 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -256,6 +256,8 @@ import { formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, + resolveProviderSkillsForCwd, + resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; @@ -890,6 +892,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); + const selectedProviderSkills = selectedProviderStatus + ? resolveProviderSkillsForCwd(selectedProviderStatus, gitCwd) + : []; + const selectedProviderSlashCommands = selectedProviderStatus + ? resolveProviderSlashCommandsForCwd(selectedProviderStatus, gitCwd) + : []; const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -1112,11 +1120,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : []), ] satisfies ReadonlyArray>; const slashMenuSkills = getProviderSkillsForSlashMenu( - selectedProviderStatus?.skills ?? [], + selectedProviderSkills, settings.showSkillsInSlashMenu, ); const providerSlashCommandItems = getProviderSlashCommandsForSlashMenu( - selectedProviderStatus?.slashCommands ?? [], + selectedProviderSlashCommands, slashMenuSkills, ).map((command) => ({ id: `provider-slash-command:${selectedProvider}:${command.name}`, @@ -1146,25 +1154,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( - (skill) => ({ - id: `skill:${selectedProvider}:${skill.name}`, - type: "skill" as const, - provider: selectedProvider, - skill, - label: formatProviderSkillDisplayName(skill), - description: - skill.shortDescription ?? - skill.description ?? - (skill.scope ? `${skill.scope} skill` : "Run provider skill"), - }), - ); + return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + })); } return []; }, [ composerTrigger, planModeUiEnabled, selectedProvider, + selectedProviderSkills, + selectedProviderSlashCommands, selectedProviderStatus, settings.showSkillsInSlashMenu, workspaceEntries.entries, @@ -3235,7 +3243,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={selectedProviderSkills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 08c79f5d8718..acebdf9658c4 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -1,12 +1,37 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, + resolveProviderSkillsForCwd, + resolveProviderSlashCommandsForCwd, resolveProviderSkillSourceKind, } from "./providerSkills.ts"; +const provider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + slashCommands: [{ name: "global" }], + skills: [{ name: "global", path: "/global/SKILL.md", enabled: true }], + workspaceSnapshots: [ + { + cwd: "/workspace/project-a", + checkedAt: "2026-01-01T00:01:00.000Z", + slashCommands: [{ name: "project" }], + skills: [{ name: "project", path: "/workspace/project-a/SKILL.md", enabled: true }], + }, + ], +} satisfies ServerProvider; + describe("formatProviderSkillDisplayName", () => { it("prefers the provider display name", () => { expect( @@ -118,3 +143,19 @@ describe("resolveProviderSkillSourceKind", () => { ).toBe("other"); }); }); + +describe("workspace provider snapshots", () => { + it("uses the cwd snapshot after a provider session has populated it", () => { + expect(resolveProviderSkillsForCwd(provider, "/workspace/project-a")).toEqual([ + { name: "project", path: "/workspace/project-a/SKILL.md", enabled: true }, + ]); + expect(resolveProviderSlashCommandsForCwd(provider, "/workspace/project-a")).toEqual([ + { name: "project" }, + ]); + }); + + it("keeps the machine snapshot before this cwd has a provider snapshot", () => { + expect(resolveProviderSkillsForCwd(provider, "/workspace/project-b")).toEqual(provider.skills); + expect(resolveProviderSlashCommandsForCwd(provider, null)).toEqual(provider.slashCommands); + }); +}); diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index faab12799ba9..dba1814e5546 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -1,4 +1,8 @@ -import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; +import type { + ServerProvider, + ServerProviderSkill, + ServerProviderSlashCommand, +} from "@t3tools/contracts"; export type ProviderSkillSourceKind = "app" | "repo" | "project" | "personal" | "system" | "other"; @@ -69,3 +73,25 @@ export function resolveProviderSkillSourceKind( return "other"; } } + +function resolveProviderWorkspaceSnapshot( + provider: ServerProvider, + cwd: string | null | undefined, +) { + if (!cwd) return undefined; + return provider.workspaceSnapshots?.find((snapshot) => snapshot.cwd === cwd); +} + +export function resolveProviderSkillsForCwd( + provider: ServerProvider, + cwd: string | null | undefined, +): ServerProvider["skills"] { + return resolveProviderWorkspaceSnapshot(provider, cwd)?.skills ?? provider.skills; +} + +export function resolveProviderSlashCommandsForCwd( + provider: ServerProvider, + cwd: string | null | undefined, +): ServerProvider["slashCommands"] { + return resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommands ?? provider.slashCommands; +} diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f62185..a6f9187a1f93 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -96,6 +96,14 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderWorkspaceSnapshot = Schema.Struct({ + cwd: TrimmedNonEmptyString, + checkedAt: IsoDateTime, + slashCommands: Schema.Array(ServerProviderSlashCommand), + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type; + /** * Availability of a configured provider instance from the runtime's POV. * @@ -192,6 +200,7 @@ export const ServerProvider = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed([])), ), skills: Schema.Array(ServerProviderSkill).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + workspaceSnapshots: Schema.optionalKey(Schema.Array(ServerProviderWorkspaceSnapshot)), versionAdvisory: Schema.optionalKey(ServerProviderVersionAdvisory), updateState: Schema.optionalKey(ServerProviderUpdateState), });