From 51e474a9fdbd8a8a4b1a914640bc527ae9ad55ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20S=C3=A1ez?= Date: Thu, 4 Jun 2026 14:34:48 -0400 Subject: [PATCH] Fix Codex workspace skill discovery --- .../src/provider/Drivers/ClaudeDriver.ts | 2 +- .../src/provider/Drivers/CodexDriver.ts | 15 ++- .../src/provider/Drivers/CursorDriver.ts | 2 +- .../src/provider/Drivers/OpenCodeDriver.ts | 2 +- .../src/provider/Layers/CodexProvider.ts | 3 +- .../Layers/ProviderAdapterRegistry.test.ts | 2 +- .../provider/Layers/ProviderRegistry.test.ts | 99 ++++++++++++++++++- .../src/provider/Layers/ProviderRegistry.ts | 40 +++++--- .../src/provider/Services/ProviderRegistry.ts | 7 +- .../src/provider/Services/ServerProvider.ts | 6 +- .../makeManagedServerProvider.test.ts | 67 +++++++++---- .../src/provider/makeManagedServerProvider.ts | 46 +++++++-- apps/server/src/ws.ts | 12 ++- apps/web/src/components/ChatView.tsx | 30 ++++++ apps/web/src/components/chat/ChatComposer.tsx | 31 +++--- .../chat/ComposerCommandMenu.test.ts | 47 +++++++++ .../components/chat/ComposerCommandMenu.tsx | 32 +----- .../chat/composerCommandMenuGroups.ts | 37 +++++++ apps/web/src/localApi.test.ts | 13 ++- apps/web/src/localApi.ts | 4 +- packages/contracts/src/ipc.ts | 1 + packages/contracts/src/rpc.ts | 5 + 22 files changed, 393 insertions(+), 110 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerCommandMenu.test.ts create mode 100644 apps/web/src/components/chat/composerCommandMenuGroups.ts diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index b126028f813..5269dc121c9 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -170,7 +170,7 @@ export const ClaudeDriver: ProviderDriver = { haveSettingsChanged: () => false, initialSnapshot: (settings) => makePendingClaudeProvider(settings).pipe(Effect.map(stampIdentity)), - checkProvider, + checkProvider: () => checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 441edda479f..33eadd48e35 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -112,6 +112,7 @@ export const CodexDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); @@ -159,10 +160,16 @@ export const CodexDriver: ProviderDriver = { // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits // `makeManagedServerProvider.checkProvider`'s `R = never`. - const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( - Effect.map(stampIdentity), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); + const checkProvider = (refreshInput?: { readonly cwd?: string | undefined }) => + checkCodexProviderStatus( + effectiveConfig, + undefined, + processEnv, + refreshInput?.cwd ?? serverConfig.cwd, + ).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); const snapshot = yield* makeManagedServerProvider({ maintenanceCapabilities, getSettings: Effect.succeed(effectiveConfig), diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index ba532864c45..d3681288888 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -137,7 +137,7 @@ export const CursorDriver: ProviderDriver = { haveSettingsChanged: () => false, initialSnapshot: (settings) => buildInitialCursorProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), - checkProvider, + checkProvider: () => checkProvider, // Model catalog and capabilities come exclusively from Cursor's // list_available_models extension method during provider checks. enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index e7216f83366..39c61a6abb1 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -149,7 +149,7 @@ export const OpenCodeDriver: ProviderDriver haveSettingsChanged: () => false, initialSnapshot: (settings) => makePendingOpenCodeProvider(settings).pipe(Effect.map(stampIdentity)), - checkProvider, + checkProvider: () => checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 4d793194c1d..2faaee91b9f 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -418,6 +418,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > = probeCodexAppServerProvider, environment: NodeJS.ProcessEnv = process.env, + cwd: string = process.cwd(), ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, @@ -446,7 +447,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const probeResult = yield* probe({ binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, - cwd: process.cwd(), + cwd, customModels: codexSettings.customModels, environment, }).pipe( diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 7fb545b2bed..d0a975bf29e 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -120,7 +120,7 @@ const makeFakeInstance = ( packageName: null, }), getSnapshot: Effect.succeed({} as unknown as ServerProvider), - refresh: Effect.succeed({} as unknown as ServerProvider), + refresh: () => Effect.succeed({} as unknown as ServerProvider), streamChanges: Stream.empty, }, adapter, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 8b564dceaa6..c9ce60460d4 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -48,6 +48,7 @@ import { readProviderStatusCache, resolveProviderStatusCachePath } from "../prov import type { ProviderInstance } from "../ProviderDriver.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry } from "../Services/ProviderRegistry.ts"; +import type { ProviderSnapshotRefreshInput } from "../Services/ServerProvider.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; const decodeServerSettings = Schema.decodeSync(ServerSettings); const encodeServerSettings = Schema.encodeSync(ServerSettings); @@ -368,6 +369,23 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ); + it.effect("passes the supplied cwd to the app-server probe", () => + Effect.gen(function* () { + let capturedCwd: string | null = null; + yield* checkCodexProviderStatus( + defaultCodexSettings, + (input) => { + capturedCwd = input.cwd; + return Effect.succeed(makeCodexProbeSnapshot()); + }, + process.env, + "/workspace/project", + ); + + assert.strictEqual(capturedCwd, "/workspace/project"); + }), + ); + it.effect( "returns ready with unknown auth when app-server does not require OpenAI auth", () => @@ -709,7 +727,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T packageName: null, }), getSnapshot: Effect.succeed(initialProvider), - refresh: Effect.succeed(refreshedProvider), + refresh: () => Effect.succeed(refreshedProvider), streamChanges: Stream.fromPubSub(changes), }, adapter: {} as ProviderInstance["adapter"], @@ -803,7 +821,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T packageName: null, }), getSnapshot: Effect.succeed(cachedProvider), - refresh: Effect.die(new Error("simulated refresh failure")), + refresh: () => Effect.die(new Error("simulated refresh failure")), streamChanges: Stream.empty, }, adapter: {} as ProviderInstance["adapter"], @@ -845,6 +863,81 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ); + it.effect("forwards manual refresh input to the targeted provider instance", () => + Effect.gen(function* () { + const codexDriver = ProviderDriverKind.make("codex"); + const codexInstanceId = ProviderInstanceId.make("codex"); + const refreshInputs = yield* Ref.make>([]); + const provider = { + instanceId: codexInstanceId, + driver: codexDriver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-29T10:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const instance = { + instanceId: codexInstanceId, + driverKind: codexDriver, + continuationIdentity: { + driverKind: codexDriver, + continuationKey: "codex:instance:codex", + }, + displayName: undefined, + enabled: true, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: codexDriver, + packageName: null, + }), + getSnapshot: Effect.succeed(provider), + refresh: (input?: ProviderSnapshotRefreshInput) => + Ref.update(refreshInputs, (inputs) => [...inputs, input?.cwd]).pipe( + Effect.as(provider), + ), + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + } satisfies ProviderInstance; + const instanceRegistryLayer = Layer.succeed(ProviderInstanceRegistry, { + getInstance: (instanceId) => + Effect.succeed(instanceId === codexInstanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }); + 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-refresh-input-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry; + yield* registry.refreshInstance(codexInstanceId, { cwd: "/workspace/project" }); + + assert.strictEqual((yield* Ref.get(refreshInputs)).at(-1), "/workspace/project"); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("keeps consuming registry changes after one sync fails", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); @@ -892,7 +985,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T packageName: null, }), getSnapshot: Effect.succeed(provider), - refresh: Effect.succeed(provider), + refresh: () => Effect.succeed(provider), streamChanges: Stream.empty, }, adapter: {} as ProviderInstance["adapter"], diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 22a120f0a52..1283cafcdd9 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -43,6 +43,7 @@ import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "../../config.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; +import type { ProviderSnapshotRefreshInput } from "../Services/ServerProvider.ts"; import { hydrateCachedProvider, isCachedProviderCorrelated, @@ -429,27 +430,33 @@ export const ProviderRegistryLive = Layer.effect( const refreshOneSource = Effect.fn("refreshOneSource")(function* ( providerSource: ProviderSnapshotSource, + input?: ProviderSnapshotRefreshInput, ) { - return yield* providerSource.refresh.pipe( - Effect.flatMap((nextProvider) => - correlateSnapshotWithSource(providerSource, nextProvider).pipe( - Effect.flatMap(syncProvider), + return yield* providerSource + .refresh(input) + .pipe( + Effect.flatMap((nextProvider) => + correlateSnapshotWithSource(providerSource, nextProvider).pipe( + Effect.flatMap(syncProvider), + ), ), - ), - ); + ); }); - const refreshAll = Effect.fn("refreshAll")(function* () { + const refreshAll = Effect.fn("refreshAll")(function* (input?: ProviderSnapshotRefreshInput) { const sources = yield* getLiveSources; - return yield* Effect.forEach(sources, (source) => refreshOneSource(source), { + return yield* Effect.forEach(sources, (source) => refreshOneSource(source, input), { concurrency: "unbounded", discard: true, }).pipe(Effect.andThen(Ref.get(providersRef))); }); - const refresh = Effect.fn("refresh")(function* (provider?: ProviderDriverKind) { + const refresh = Effect.fn("refresh")(function* ( + provider?: ProviderDriverKind, + input?: ProviderSnapshotRefreshInput, + ) { if (provider === undefined) { - return yield* refreshAll(); + return yield* refreshAll(input); } // Kind-scoped refreshes target the default instance for that driver. const defaultInstanceId = defaultInstanceIdForDriver(provider); @@ -460,18 +467,19 @@ export const ProviderRegistryLive = Layer.effect( if (!providerSource) { return yield* Ref.get(providersRef); } - return yield* refreshOneSource(providerSource); + return yield* refreshOneSource(providerSource, input); }); const refreshInstance = Effect.fn("refreshInstance")(function* ( instanceId: ProviderInstanceId, + input?: ProviderSnapshotRefreshInput, ) { const sources = yield* getLiveSources; const providerSource = sources.find((candidate) => candidate.instanceId === instanceId); if (!providerSource) { return yield* Ref.get(providersRef); } - return yield* refreshOneSource(providerSource); + return yield* refreshOneSource(providerSource, input); }); const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( @@ -681,10 +689,10 @@ export const ProviderRegistryLive = Layer.effect( return { getProviders: Ref.get(providersRef), - refresh: (provider?: ProviderDriverKind) => - refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), - refreshInstance: (instanceId: ProviderInstanceId) => - refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + refresh: (provider?: ProviderDriverKind, input?: ProviderSnapshotRefreshInput) => + refresh(provider, input).pipe(Effect.catchCause(recoverRefreshFailure)), + refreshInstance: (instanceId: ProviderInstanceId, input?: ProviderSnapshotRefreshInput) => + refreshInstance(instanceId, input).pipe(Effect.catchCause(recoverRefreshFailure)), getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b30338..af37f341334 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -16,6 +16,7 @@ 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 { ProviderSnapshotRefreshInput } from "./ServerProvider.ts"; export type ProviderMaintenanceActionKind = "update"; @@ -36,7 +37,10 @@ export interface ProviderRegistryShape { * * @deprecated prefer `refreshInstance` for new call sites. */ - readonly refresh: (provider?: ProviderDriverKind) => Effect.Effect>; + readonly refresh: ( + provider?: ProviderDriverKind, + input?: ProviderSnapshotRefreshInput, + ) => Effect.Effect>; /** * Refresh the specific configured instance. Returns the updated snapshot @@ -46,6 +50,7 @@ export interface ProviderRegistryShape { */ readonly refreshInstance: ( instanceId: ProviderInstanceId, + input?: ProviderSnapshotRefreshInput, ) => Effect.Effect>; /** diff --git a/apps/server/src/provider/Services/ServerProvider.ts b/apps/server/src/provider/Services/ServerProvider.ts index 12162512927..9f66e416968 100644 --- a/apps/server/src/provider/Services/ServerProvider.ts +++ b/apps/server/src/provider/Services/ServerProvider.ts @@ -3,9 +3,13 @@ import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +export interface ProviderSnapshotRefreshInput { + readonly cwd?: string | undefined; +} + export interface ServerProviderShape { readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; readonly getSnapshot: Effect.Effect; - readonly refresh: Effect.Effect; + readonly refresh: (input?: ProviderSnapshotRefreshInput) => Effect.Effect; readonly streamChanges: Stream.Stream; } diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index 1f3ebeab089..f7d94d944d7 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -114,10 +114,11 @@ describe("makeManagedServerProvider", () => { streamSettings: Stream.empty, haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, initialSnapshot: () => Effect.succeed(initialSnapshot), - checkProvider: Ref.update(checkCalls, (count) => count + 1).pipe( - Effect.flatMap(() => Deferred.await(releaseCheck)), - Effect.as(refreshedSnapshot), - ), + checkProvider: () => + Ref.update(checkCalls, (count) => count + 1).pipe( + Effect.flatMap(() => Deferred.await(releaseCheck)), + Effect.as(refreshedSnapshot), + ), refreshInterval: "1 hour", }); @@ -156,13 +157,14 @@ describe("makeManagedServerProvider", () => { streamSettings: Stream.fromPubSub(settingsChanges), haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, initialSnapshot: () => Effect.succeed(initialSnapshot), - checkProvider: Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( - Effect.flatMap((count) => - count === 1 - ? Deferred.await(releaseInitialCheck).pipe(Effect.as(refreshedSnapshot)) - : Deferred.await(releaseSettingsCheck).pipe(Effect.as(refreshedSnapshotSecond)), + checkProvider: () => + Ref.updateAndGet(checkCalls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Deferred.await(releaseInitialCheck).pipe(Effect.as(refreshedSnapshot)) + : Deferred.await(releaseSettingsCheck).pipe(Effect.as(refreshedSnapshotSecond)), + ), ), - ), refreshInterval: "1 hour", }); @@ -198,7 +200,7 @@ describe("makeManagedServerProvider", () => { streamSettings: Stream.empty, haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, initialSnapshot: () => Effect.succeed(initialSnapshot), - checkProvider: Deferred.await(releaseCheck).pipe(Effect.as(refreshedSnapshot)), + checkProvider: () => Deferred.await(releaseCheck).pipe(Effect.as(refreshedSnapshot)), enrichSnapshot: ({ publishSnapshot }) => Deferred.await(releaseEnrichment).pipe( Effect.flatMap(() => publishSnapshot(enrichedSnapshot)), @@ -239,13 +241,14 @@ describe("makeManagedServerProvider", () => { streamSettings: Stream.empty, haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, initialSnapshot: () => Effect.succeed(initialSnapshot), - checkProvider: Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( - Effect.flatMap((count) => - count === 1 - ? Deferred.await(allowFirstRefresh).pipe(Effect.as(refreshedSnapshot)) - : Effect.succeed(refreshedSnapshotSecond), + checkProvider: () => + Ref.updateAndGet(refreshCount, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 + ? Deferred.await(allowFirstRefresh).pipe(Effect.as(refreshedSnapshot)) + : Effect.succeed(refreshedSnapshotSecond), + ), ), - ), enrichSnapshot: ({ publishSnapshot }) => Effect.gen(function* () { publishCallbacks.push(publishSnapshot); @@ -267,7 +270,7 @@ describe("makeManagedServerProvider", () => { yield* Deferred.succeed(allowFirstRefresh, undefined); yield* Deferred.await(firstCallbackReady); - yield* provider.refresh; + yield* provider.refresh(); yield* Deferred.await(secondCallbackReady); yield* publishCallbacks[0]!(enrichedSnapshot); @@ -285,4 +288,32 @@ describe("makeManagedServerProvider", () => { }), ), ); + + it.effect("remembers explicit refresh input for later background checks", () => + Effect.scoped( + Effect.gen(function* () { + const refreshInputs = yield* Ref.make>([]); + const provider = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: (previous, next) => previous.enabled !== next.enabled, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: (input) => + Ref.update(refreshInputs, (inputs) => [...inputs, input?.cwd]).pipe( + Effect.as(refreshedSnapshot), + ), + refreshInterval: "1 hour", + }); + + yield* provider.refresh({ cwd: "/workspace/project" }); + yield* provider.refresh(); + + assert.deepStrictEqual((yield* Ref.get(refreshInputs)).slice(-2), [ + "/workspace/project", + "/workspace/project", + ]); + }), + ), + ); }); diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index 2f07c5d508c..65c8e4a5591 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -9,7 +9,10 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; -import type { ServerProviderShape } from "./Services/ServerProvider.ts"; +import type { + ProviderSnapshotRefreshInput, + ServerProviderShape, +} from "./Services/ServerProvider.ts"; import { ServerSettingsError } from "@t3tools/contracts"; interface ProviderSnapshotState { @@ -25,7 +28,9 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly streamSettings: Stream.Stream; readonly haveSettingsChanged: (previous: Settings, next: Settings) => boolean; readonly initialSnapshot: (settings: Settings) => Effect.Effect; - readonly checkProvider: Effect.Effect; + readonly checkProvider: ( + input?: ProviderSnapshotRefreshInput, + ) => Effect.Effect; readonly enrichSnapshot?: (input: { readonly settings: Settings; readonly snapshot: ServerProvider; @@ -46,6 +51,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( enrichmentGeneration: 0, }); const settingsRef = yield* Ref.make(initialSettings); + const refreshInputRef = yield* Ref.make(undefined); const enrichmentFiberRef = yield* Ref.make | null>(null); const scope = yield* Effect.scope; @@ -97,9 +103,22 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( yield* Ref.set(enrichmentFiberRef, fiber); }); + const resolveRefreshInput = Effect.fn("resolveRefreshInput")(function* ( + nextInput: ProviderSnapshotRefreshInput | undefined, + ) { + if (nextInput !== undefined) { + yield* Ref.set(refreshInputRef, nextInput); + return nextInput; + } + return yield* Ref.get(refreshInputRef); + }); + const applySnapshotBase = Effect.fn("applySnapshot")(function* ( nextSettings: Settings, - options?: { readonly forceRefresh?: boolean }, + options?: { + readonly forceRefresh?: boolean; + readonly refreshInput?: ProviderSnapshotRefreshInput | undefined; + }, ) { const forceRefresh = options?.forceRefresh === true; const previousSettings = yield* Ref.get(settingsRef); @@ -108,7 +127,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return yield* Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)); } - const nextSnapshot = yield* input.checkProvider; + const refreshInput = yield* resolveRefreshInput(options?.refreshInput); + const nextSnapshot = yield* input.checkProvider(refreshInput); const nextGeneration = yield* Ref.modify(snapshotStateRef, (state) => { const generation = input.enrichSnapshot ? state.enrichmentGeneration + 1 @@ -126,12 +146,19 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( yield* restartSnapshotEnrichment(nextSettings, nextSnapshot, nextGeneration); return nextSnapshot; }); - const applySnapshot = (nextSettings: Settings, options?: { readonly forceRefresh?: boolean }) => - refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); + const applySnapshot = ( + nextSettings: Settings, + options?: { + readonly forceRefresh?: boolean; + readonly refreshInput?: ProviderSnapshotRefreshInput | undefined; + }, + ) => refreshSemaphore.withPermits(1)(applySnapshotBase(nextSettings, options)); - const refreshSnapshot = Effect.fn("refreshSnapshot")(function* () { + const refreshSnapshot = Effect.fn("refreshSnapshot")(function* ( + refreshInput?: ProviderSnapshotRefreshInput, + ) { const nextSettings = yield* input.getSettings; - return yield* applySnapshot(nextSettings, { forceRefresh: true }); + return yield* applySnapshot(nextSettings, { forceRefresh: true, refreshInput }); }); yield* Stream.runForEach(input.streamSettings, (nextSettings) => @@ -157,7 +184,8 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( Effect.tapError(Effect.logError), Effect.orDie, ), - refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), + refresh: (refreshInput) => + refreshSnapshot(refreshInput).pipe(Effect.tapError(Effect.logError), Effect.orDie), get streamChanges() { return Stream.fromPubSub(changesPubSub); }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index da8e3f694db..3f14c876586 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -987,10 +987,14 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, - (input.instanceId !== undefined - ? providerRegistry.refreshInstance(input.instanceId) - : providerRegistry.refresh() - ).pipe(Effect.map((providers) => ({ providers }))), + Effect.gen(function* () { + const refreshInput = input.cwd !== undefined ? { cwd: input.cwd } : undefined; + const providers = + input.instanceId !== undefined + ? yield* providerRegistry.refreshInstance(input.instanceId, refreshInput) + : yield* providerRegistry.refresh(undefined, refreshInput); + return { providers }; + }), { "rpc.aggregate": "server" }, ), [WS_METHODS.serverUpdateProvider]: (input) => diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7fc7669fe60..7429f807e00 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -202,6 +202,7 @@ const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const CODEX_PROVIDER_DRIVER = ProviderDriverKind.make("codex"); type EnvironmentUnavailableState = { readonly environmentId: EnvironmentId; readonly label: string; @@ -844,6 +845,7 @@ export default function ChatView(props: ChatViewProps) { const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const localComposerRef = useRef(null); + const codexWorkspaceProviderRefreshKeyRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); @@ -1859,6 +1861,34 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.cwd ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; + useEffect(() => { + if (!activeWorkspaceRoot) return; + const refreshProviderInstanceId = + activeProviderInstanceId ?? defaultInstanceIdForDriver(selectedProvider); + const refreshProviderDriver = activeProviderStatus?.driver ?? selectedProvider; + if (refreshProviderDriver !== CODEX_PROVIDER_DRIVER) return; + + const refreshKey = `${environmentId}\u0000${refreshProviderInstanceId}\u0000${activeWorkspaceRoot}`; + if (codexWorkspaceProviderRefreshKeyRef.current === refreshKey) return; + + const api = readLocalApi(); + if (!api) return; + codexWorkspaceProviderRefreshKeyRef.current = refreshKey; + void api.server + .refreshProviders({ + instanceId: refreshProviderInstanceId, + cwd: activeWorkspaceRoot, + }) + .catch((error: unknown) => { + console.warn("Failed to refresh Codex provider for active workspace", error); + }); + }, [ + activeProviderInstanceId, + activeProviderStatus?.driver, + activeWorkspaceRoot, + environmentId, + selectedProvider, + ]); const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d6677fa172a..3c33e157fa4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -843,6 +843,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; + const providerSkillItems = (query: string) => + searchProviderSkills(selectedProviderStatus?.skills ?? [], 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"), + })); if (composerTrigger.kind === "path") { return workspaceEntries.entries.map((entry) => ({ id: `path:${entry.kind}:${entry.path}`, @@ -889,25 +901,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const query = composerTrigger.query.trim().toLowerCase(); const slashCommandItems = [...builtInSlashCommandItems, ...providerSlashCommandItems]; + const skillItems = providerSkillItems(query); if (!query) { - return slashCommandItems; + return [...slashCommandItems, ...skillItems]; } - return searchSlashCommandItems(slashCommandItems, query); + return [...searchSlashCommandItems(slashCommandItems, query), ...skillItems]; } 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 providerSkillItems(composerTrigger.query); } return []; }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.test.ts b/apps/web/src/components/chat/ComposerCommandMenu.test.ts new file mode 100644 index 00000000000..5062cdfe057 --- /dev/null +++ b/apps/web/src/components/chat/ComposerCommandMenu.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ProviderDriverKind } from "@t3tools/contracts"; + +import type { ComposerCommandItem } from "./ComposerCommandMenu"; +import { groupCommandItems } from "./composerCommandMenuGroups"; + +describe("groupCommandItems", () => { + it("groups provider skills in slash command suggestions", () => { + const codexDriver = ProviderDriverKind.make("codex"); + const items = [ + { + id: "slash:model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch response model for this thread", + }, + { + id: "provider-slash-command:codex:status", + type: "provider-slash-command", + provider: codexDriver, + command: { name: "status" }, + label: "/status", + description: "Show provider status", + }, + { + id: "skill:codex:create-staging-pr", + type: "skill", + provider: codexDriver, + skill: { + name: "create-staging-pr", + path: "/workspace/.agents/skills/create-staging-pr/SKILL.md", + enabled: true, + shortDescription: "Create PR to staging", + }, + label: "Create Staging PR", + description: "Create PR to staging", + }, + ] satisfies ComposerCommandItem[]; + + expect(groupCommandItems(items, "slash-command", true).map((group) => group.label)).toEqual([ + "Built-in", + "Provider", + "Skills", + ]); + }); +}); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index f687ec7ba23..1c40b28ad5c 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -18,6 +18,7 @@ import { CommandList, CommandSeparator, } from "../ui/command"; +import { groupCommandItems } from "./composerCommandMenuGroups"; import { VscodeEntryIcon } from "./VscodeEntryIcon"; export type ComposerCommandItem = @@ -53,12 +54,6 @@ export type ComposerCommandItem = description: string; }; -type ComposerCommandGroup = { - id: string; - label: string | null; - items: ComposerCommandItem[]; -}; - function SkillGlyph(props: { className?: string }) { return ( 0 ? [{ id: "skills", label: "Skills", items }] : []; - } - if (triggerKind !== "slash-command" || !groupSlashCommandSections) { - return [{ id: "default", label: null, items }]; - } - - const builtInItems = items.filter((item) => item.type === "slash-command"); - const providerItems = items.filter((item) => item.type === "provider-slash-command"); - - const groups: ComposerCommandGroup[] = []; - if (builtInItems.length > 0) { - groups.push({ id: "built-in", label: "Built-in", items: builtInItems }); - } - if (providerItems.length > 0) { - groups.push({ id: "provider", label: "Provider", items: providerItems }); - } - return groups; -} - export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { items: ComposerCommandItem[]; resolvedTheme: "light" | "dark"; diff --git a/apps/web/src/components/chat/composerCommandMenuGroups.ts b/apps/web/src/components/chat/composerCommandMenuGroups.ts new file mode 100644 index 00000000000..393c5a2b9c4 --- /dev/null +++ b/apps/web/src/components/chat/composerCommandMenuGroups.ts @@ -0,0 +1,37 @@ +import type { ComposerTriggerKind } from "../../composer-logic"; +import type { ComposerCommandItem } from "./ComposerCommandMenu"; + +export type ComposerCommandGroup = { + id: string; + label: string | null; + items: ComposerCommandItem[]; +}; + +export function groupCommandItems( + items: ComposerCommandItem[], + triggerKind: ComposerTriggerKind | null, + groupSlashCommandSections: boolean, +): ComposerCommandGroup[] { + if (triggerKind === "skill") { + return items.length > 0 ? [{ id: "skills", label: "Skills", items }] : []; + } + if (triggerKind !== "slash-command" || !groupSlashCommandSections) { + return [{ id: "default", label: null, items }]; + } + + const builtInItems = items.filter((item) => item.type === "slash-command"); + const providerItems = items.filter((item) => item.type === "provider-slash-command"); + const skillItems = items.filter((item) => item.type === "skill"); + + const groups: ComposerCommandGroup[] = []; + if (builtInItems.length > 0) { + groups.push({ id: "built-in", label: "Built-in", items: builtInItems }); + } + if (providerItems.length > 0) { + groups.push({ id: "provider", label: "Provider", items: providerItems }); + } + if (skillItems.length > 0) { + groups.push({ id: "skills", label: "Skills", items: skillItems }); + } + return groups; +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 161d6a9ef44..f718459e96e 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -542,7 +542,18 @@ describe("wsApi", () => { const api = createLocalApi(rpcClientMock as never); await expect(api.server.refreshProviders()).resolves.toEqual({ providers: nextProviders }); - expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(); + expect(rpcClientMock.server.refreshProviders).toHaveBeenCalledWith(undefined); + + await expect( + api.server.refreshProviders({ + instanceId: defaultProviders[0]!.instanceId, + cwd: "/workspace/project", + }), + ).resolves.toEqual({ providers: nextProviders }); + expect(rpcClientMock.server.refreshProviders).toHaveBeenLastCalledWith({ + instanceId: defaultProviders[0]!.instanceId, + cwd: "/workspace/project", + }); }); it("forwards provider updates directly to the RPC client", async () => { diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index c5ee3f277ca..36947a29e87 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -121,9 +121,9 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { server: { getConfig: () => rpcClient ? rpcClient.server.getConfig() : Promise.reject(unavailableLocalBackendError()), - refreshProviders: () => + refreshProviders: (input) => rpcClient - ? rpcClient.server.refreshProviders() + ? rpcClient.server.refreshProviders(input) : Promise.reject(unavailableLocalBackendError()), updateProvider: (input) => rpcClient diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 684974fcac5..813919ed87c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -471,6 +471,7 @@ export interface LocalApi { */ refreshProviders: (input?: { readonly instanceId?: ProviderInstanceId; + readonly cwd?: string; }) => Promise; updateProvider: (input: ServerProviderUpdateInput) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 814e403b64c..56f5fc41ca6 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -207,6 +207,11 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv * refreshes. */ instanceId: Schema.optional(ProviderInstanceId), + /** + * Workspace directory to use for provider probes that expose project-local + * metadata, such as Codex skills. + */ + cwd: Schema.optional(Schema.String), }), success: ServerProviderUpdatedPayload, error: EnvironmentAuthorizationError,