diff --git a/lib/shared/src/context/openctx/api.ts b/lib/shared/src/context/openctx/api.ts index 4f915c693185..f1949df848af 100644 --- a/lib/shared/src/context/openctx/api.ts +++ b/lib/shared/src/context/openctx/api.ts @@ -1,29 +1,42 @@ import type { Client } from '@openctx/client' +import type { Observable } from 'observable-fns' import type * as vscode from 'vscode' +import { fromLateSetSource, shareReplay, storeLastValue } from '../../misc/observable' -type OpenCtxController = Pick< +export type OpenCtxController = Pick< Client, 'meta' | 'metaChanges' | 'mentions' | 'mentionsChanges' | 'items' -> & {} +> -interface OpenCtx { - controller?: OpenCtxController - disposable?: vscode.Disposable -} +const _openctxController = fromLateSetSource() -export const openCtx: OpenCtx = {} +export const openctxController: Observable = _openctxController.observable.pipe( + shareReplay({ shouldCountRefs: false }) +) /** - * Set the handle to the OpenCtx. If there is an existing handle it will be - * disposed and replaced. + * Set the observable that will be used to provide the global {@link openctxController}. */ -export function setOpenCtx({ controller, disposable }: OpenCtx): void { - const { disposable: oldDisposable } = openCtx +export function setOpenCtxControllerObservable(input: Observable): void { + _openctxController.setSource(input) +} - openCtx.controller = controller - openCtx.disposable = disposable +const { value: syncValue } = storeLastValue(openctxController) - oldDisposable?.dispose() +/** + * The current OpenCtx controller. Callers should use {@link openctxController} instead so that + * they react to changes. This function is provided for old call sites that haven't been updated + * to use an Observable. + * + * Callers should take care to avoid race conditions and prefer observing {@link openctxController}. + * + * Throws if the OpenCtx controller is not yet set. + */ +export function currentOpenCtxController(): OpenCtxController { + if (!syncValue.isSet) { + throw new Error('OpenCtx controller is not initialized') + } + return syncValue.last } export const REMOTE_REPOSITORY_PROVIDER_URI = 'internal-remote-repository-search' @@ -32,3 +45,4 @@ export const REMOTE_DIRECTORY_PROVIDER_URI = 'internal-remote-directory-search' export const WEB_PROVIDER_URI = 'internal-web-provider' export const GIT_OPENCTX_PROVIDER_URI = 'internal-git-openctx-provider' export const CODE_SEARCH_PROVIDER_URI = 'internal-code-search-provider' +export const RULES_PROVIDER_URI = 'internal-rules-provider' diff --git a/lib/shared/src/context/openctx/context.ts b/lib/shared/src/context/openctx/context.ts index 9ea62305079d..21988ca2ce17 100644 --- a/lib/shared/src/context/openctx/context.ts +++ b/lib/shared/src/context/openctx/context.ts @@ -1,6 +1,6 @@ import { URI } from 'vscode-uri' import { type ContextItemOpenCtx, ContextItemSource } from '../../codebase-context/messages' -import { openCtx } from './api' +import { currentOpenCtxController } from './api' // getContextForChatMessage returns context items for a given chat message from the OpenCtx providers. export const getContextForChatMessage = async ( @@ -8,7 +8,7 @@ export const getContextForChatMessage = async ( signal?: AbortSignal ): Promise => { try { - const openCtxClient = openCtx.controller + const openCtxClient = currentOpenCtxController() if (!openCtxClient) { return [] } @@ -52,7 +52,7 @@ export const getContextForChatMessage = async ( content: item.ai?.content || '', provider: 'openctx', source: ContextItemSource.User, // To indicate that this is a user-added item. - }) as ContextItemOpenCtx + }) satisfies ContextItemOpenCtx ) } catch { return [] diff --git a/lib/shared/src/index.ts b/lib/shared/src/index.ts index e84be69ebd63..cae72e83af54 100644 --- a/lib/shared/src/index.ts +++ b/lib/shared/src/index.ts @@ -352,14 +352,16 @@ export * from './token' export * from './token/constants' export * from './configuration' export { - setOpenCtx, - openCtx, + setOpenCtxControllerObservable, + openctxController, + type OpenCtxController, REMOTE_REPOSITORY_PROVIDER_URI, REMOTE_FILE_PROVIDER_URI, REMOTE_DIRECTORY_PROVIDER_URI, WEB_PROVIDER_URI, GIT_OPENCTX_PROVIDER_URI, CODE_SEARCH_PROVIDER_URI, + currentOpenCtxController, } from './context/openctx/api' export * from './context/openctx/context' export * from './lexicalEditor/editorState' diff --git a/lib/shared/src/mentions/api.test.ts b/lib/shared/src/mentions/api.test.ts index cef3d1cdc5ef..1ba498fad3dc 100644 --- a/lib/shared/src/mentions/api.test.ts +++ b/lib/shared/src/mentions/api.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { Observable } from 'observable-fns' +import { describe, expect, it, vi } from 'vitest' +import * as openctxAPI from '../context/openctx/api' import { firstValueFrom } from '../misc/observable' import { FILE_CONTEXT_MENTION_PROVIDER, @@ -7,6 +9,15 @@ import { } from './api' describe('mentionProvidersMetadata', () => { + vi.spyOn(openctxAPI, 'openctxController', 'get').mockReturnValue( + Observable.of({ + metaChanges: () => Observable.of([]), + } satisfies Pick< + openctxAPI.OpenCtxController, + 'metaChanges' + > as unknown as openctxAPI.OpenCtxController) + ) + it('should return all providers when no options are provided', async () => { const providers = await firstValueFrom(mentionProvidersMetadata()) expect(providers.length).toBeGreaterThanOrEqual(2) diff --git a/lib/shared/src/mentions/api.ts b/lib/shared/src/mentions/api.ts index f8b2291b616a..e28bcfdd1833 100644 --- a/lib/shared/src/mentions/api.ts +++ b/lib/shared/src/mentions/api.ts @@ -1,7 +1,7 @@ import type { MetaResult } from '@openctx/client' -import { Observable, map } from 'observable-fns' -import { openCtx } from '../context/openctx/api' -import { distinctUntilChanged } from '../misc/observable' +import { type Observable, map } from 'observable-fns' +import { openctxController } from '../context/openctx/api' +import { distinctUntilChanged, switchMap } from '../misc/observable' /** * Props required by context item providers to return possible context items. @@ -73,18 +73,17 @@ export function openCtxProviderMetadata( } function openCtxMentionProviders(): Observable { - const controller = openCtx.controller - if (!controller) { - return Observable.of([]) - } - - return controller.metaChanges({}, {}).pipe( - map(providers => - providers - .filter(provider => !!provider.mentions) - .map(openCtxProviderMetadata) - .sort((a, b) => (a.title > b.title ? 1 : -1)) - ), - distinctUntilChanged() + return openctxController.pipe( + switchMap(c => + c.metaChanges({}, {}).pipe( + map(providers => + providers + .filter(provider => !!provider.mentions) + .map(openCtxProviderMetadata) + .sort((a, b) => (a.title > b.title ? 1 : -1)) + ), + distinctUntilChanged() + ) + ) ) } diff --git a/vscode/src/chat/agentic/CodyTool.ts b/vscode/src/chat/agentic/CodyTool.ts index 795f5d0225b8..d0f40fcb529b 100644 --- a/vscode/src/chat/agentic/CodyTool.ts +++ b/vscode/src/chat/agentic/CodyTool.ts @@ -6,9 +6,9 @@ import { type ContextMentionProviderMetadata, ProcessType, PromptString, + currentOpenCtxController, firstValueFrom, logDebug, - openCtx, parseMentionQuery, pendingOperation, ps, @@ -305,7 +305,7 @@ export class OpenCtxTool extends CodyTool { async execute(span: Span, queries: string[]): Promise { span.addEvent('executeOpenCtxTool') - const openCtxClient = openCtx.controller + const openCtxClient = currentOpenCtxController() if (!queries?.length || !openCtxClient) { return [] } diff --git a/vscode/src/chat/agentic/CodyToolProvider.test.ts b/vscode/src/chat/agentic/CodyToolProvider.test.ts index 8767a7e4dd25..bba749b2cf84 100644 --- a/vscode/src/chat/agentic/CodyToolProvider.test.ts +++ b/vscode/src/chat/agentic/CodyToolProvider.test.ts @@ -1,7 +1,8 @@ -import { type ContextItem, ContextItemSource, openCtx, ps } from '@sourcegraph/cody-shared' +import { type ContextItem, ContextItemSource, ps } from '@sourcegraph/cody-shared' import { Observable } from 'observable-fns' import { beforeEach, describe, expect, it, vi } from 'vitest' import { URI } from 'vscode-uri' +import * as openctxAPI from '../../../../lib/shared/src/context/openctx/api' import { mockLocalStorage } from '../../services/LocalStorageProvider' import type { ContextRetriever } from '../chat-view/ContextRetriever' import { CodyTool, type CodyToolConfig } from './CodyTool' @@ -62,7 +63,7 @@ describe('CodyToolProvider', () => { beforeEach(() => { vi.clearAllMocks() CodyToolProvider.initialize(mockContextRetriever) - openCtx.controller = mockController + vi.spyOn(openctxAPI, 'openctxController', 'get').mockReturnValue(Observable.of(mockController)) }) it('should register default tools on initialization', () => { @@ -73,9 +74,9 @@ describe('CodyToolProvider', () => { }) it('should set up OpenCtx provider listener and build OpenCtx tools from provider metadata', async () => { - openCtx.controller = mockController CodyToolProvider.setupOpenCtxProviderListener() - expect(openCtx.controller?.metaChanges).toHaveBeenCalled() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(mockController.metaChanges).toHaveBeenCalled() // Wait for the observable to emit await new Promise(resolve => setTimeout(resolve, 0)) diff --git a/vscode/src/chat/agentic/CodyToolProvider.ts b/vscode/src/chat/agentic/CodyToolProvider.ts index 86af3011f1bc..64999cc88894 100644 --- a/vscode/src/chat/agentic/CodyToolProvider.ts +++ b/vscode/src/chat/agentic/CodyToolProvider.ts @@ -4,9 +4,10 @@ import { PromptString, type Unsubscribable, isDefined, - openCtx, openCtxProviderMetadata, + openctxController, ps, + switchMap, } from '@sourcegraph/cody-shared' import { map } from 'observable-fns' import type { ContextRetriever } from '../chat-view/ContextRetriever' @@ -190,10 +191,19 @@ export class CodyToolProvider { if (provider && !CodyToolProvider.configSubscription) { CodyToolProvider.configSubscription = toolboxManager.observable.subscribe({}) } - if (provider && !CodyToolProvider.openCtxSubscription && openCtx.controller) { - CodyToolProvider.openCtxSubscription = openCtx.controller - .metaChanges({}, {}) - .pipe(map(providers => providers.filter(p => !!p.mentions).map(openCtxProviderMetadata))) + if (provider && !CodyToolProvider.openCtxSubscription) { + CodyToolProvider.openCtxSubscription = openctxController + .pipe( + switchMap(c => + c + .metaChanges({}, {}) + .pipe( + map(providers => + providers.filter(p => !!p.mentions).map(openCtxProviderMetadata) + ) + ) + ) + ) .subscribe(providerMeta => provider.factory.createOpenCtxTools(providerMeta)) } } diff --git a/vscode/src/chat/context/chatContext.ts b/vscode/src/chat/context/chatContext.ts index 0dbf88a04d7d..dd0ac4f3363d 100644 --- a/vscode/src/chat/context/chatContext.ts +++ b/vscode/src/chat/context/chatContext.ts @@ -11,12 +11,12 @@ import { SYMBOL_CONTEXT_MENTION_PROVIDER, clientCapabilities, combineLatest, + currentOpenCtxController, firstResultFromOperation, fromVSCodeEvent, isAbortError, isError, mentionProvidersMetadata, - openCtx, pendingOperation, promiseFactoryToObservable, skipPendingOperation, @@ -153,11 +153,7 @@ export async function getChatContextItemsForMention( } default: { - if (!openCtx.controller) { - return [] - } - - const items = await openCtx.controller.mentions( + const items = await currentOpenCtxController().mentions( { query: mentionQuery.text, ...(await firstResultFromOperation(activeEditorContextForOpenCtxMentions)), diff --git a/vscode/src/chat/initialContext.ts b/vscode/src/chat/initialContext.ts index 1ae2e3cb4ee3..6daea03b9d2f 100644 --- a/vscode/src/chat/initialContext.ts +++ b/vscode/src/chat/initialContext.ts @@ -20,7 +20,7 @@ import { fromVSCodeEvent, isDotCom, isError, - openCtx, + openctxController, pendingOperation, shareReplay, startWith, @@ -265,53 +265,52 @@ export function getCorpusContextItemsForEditorState(): Observable< } function getOpenCtxContextItems(): Observable { - const openctxController = openCtx.controller - if (!openctxController) { - return Observable.of([]) - } - - return openctxController.metaChanges({}).pipe( - switchMap((providers): Observable => { - const providersWithAutoInclude = providers.filter(meta => meta.mentions?.autoInclude) - if (providersWithAutoInclude.length === 0) { - return Observable.of([]) - } - - return activeTextEditor.pipe( - debounceTime(50), - switchMap(() => activeEditorContextForOpenCtxMentions), - switchMap(activeEditorContext => { - if (activeEditorContext === pendingOperation) { - return Observable.of(pendingOperation) - } - if (isError(activeEditorContext)) { + return openctxController.pipe( + switchMap(c => + c.metaChanges({}, {}).pipe( + switchMap((providers): Observable => { + const providersWithAutoInclude = providers.filter(meta => meta.mentions?.autoInclude) + if (providersWithAutoInclude.length === 0) { return Observable.of([]) } - return combineLatest( - ...providersWithAutoInclude.map(provider => - openctxController.mentionsChanges( - { ...activeEditorContext, autoInclude: true }, - provider - ) - ) - ).pipe( - map(mentionsResults => - mentionsResults.flat().map( - mention => - ({ - ...mention, - provider: 'openctx', - type: 'openctx', - uri: URI.parse(mention.uri), - source: ContextItemSource.Initial, - mention, // include the original mention to pass to `items` later - }) satisfies ContextItem + + return activeTextEditor.pipe( + debounceTime(50), + switchMap(() => activeEditorContextForOpenCtxMentions), + switchMap(activeEditorContext => { + if (activeEditorContext === pendingOperation) { + return Observable.of(pendingOperation) + } + if (isError(activeEditorContext)) { + return Observable.of([]) + } + return combineLatest( + ...providersWithAutoInclude.map(provider => + c.mentionsChanges( + { ...activeEditorContext, autoInclude: true }, + provider + ) + ) + ).pipe( + map(mentionsResults => + mentionsResults.flat().map( + mention => + ({ + ...mention, + provider: 'openctx', + type: 'openctx', + uri: URI.parse(mention.uri), + source: ContextItemSource.Initial, + mention, // include the original mention to pass to `items` later + }) satisfies ContextItem + ) + ), + startWith(pendingOperation) ) - ), - startWith(pendingOperation) + }) ) }) ) - }) + ) ) } diff --git a/vscode/src/context/openctx.ts b/vscode/src/context/openctx.ts index 47883493982a..5900def922ab 100644 --- a/vscode/src/context/openctx.ts +++ b/vscode/src/context/openctx.ts @@ -6,6 +6,7 @@ import { type CodyClientConfig, FeatureFlag, GIT_OPENCTX_PROVIDER_URI, + type OpenCtxController, WEB_PROVIDER_URI, authStatus, clientCapabilities, @@ -21,7 +22,6 @@ import { pluck, promiseFactoryToObservable, resolvedConfig, - setOpenCtx, skipPendingOperation, switchMap, } from '@sourcegraph/cody-shared' @@ -43,10 +43,14 @@ import RemoteFileProvider, { createRemoteFileProvider } from './openctx/remoteFi import RemoteRepositorySearch, { createRemoteRepositoryProvider } from './openctx/remoteRepositorySearch' import { createWebProvider } from './openctx/web' -export function exposeOpenCtxClient( +/** + * DO NOT USE except in `main.ts` initial activation. Instead, ise the global `openctxController` + * observable to obtain the OpenCtx controller. + */ +export function observeOpenCtxController( context: Pick, createOpenCtxController: typeof createController | undefined -): Observable { +): Observable { void warnIfOpenCtxExtensionConflict() return combineLatest( @@ -76,7 +80,7 @@ export function exposeOpenCtxClient( async () => createOpenCtxController ?? (await import('@openctx/vscode-lib')).createController ) ).pipe( - createDisposables(([{ experimentalNoodle }, isValidSiteVersion, createController]) => { + map(([{ experimentalNoodle }, isValidSiteVersion, createController]) => { try { // Enable fetching of openctx configuration from Sourcegraph instance const mergeConfiguration = experimentalNoodle @@ -106,18 +110,15 @@ export function exposeOpenCtxClient( ), mergeConfiguration, }) - setOpenCtx({ - controller: controller.controller, - disposable: controller.disposable, - }) CodyToolProvider.setupOpenCtxProviderListener() - return controller.disposable + return controller } catch (error) { logDebug('openctx', `Failed to load OpenCtx client: ${error}`) - return undefined + throw error } }), - map(() => undefined) + createDisposables(controller => controller.disposable), + map(controller => controller.controller) ) } diff --git a/vscode/src/editor/utils/editor-context.ts b/vscode/src/editor/utils/editor-context.ts index fe901a54476b..ade5596493bb 100644 --- a/vscode/src/editor/utils/editor-context.ts +++ b/vscode/src/editor/utils/editor-context.ts @@ -15,6 +15,7 @@ import { type SymbolKind, TokenCounterUtils, contextFiltersProvider, + currentOpenCtxController, currentResolvedConfig, displayPath, firstValueFrom, @@ -24,7 +25,6 @@ import { isErrorLike, isWindows, logError, - openCtx, toRangeData, } from '@sourcegraph/cody-shared' @@ -431,7 +431,7 @@ async function resolveContextMentionProviderContextItem( return [] } - const openCtxClient = openCtx.controller + const openCtxClient = currentOpenCtxController() if (!openCtxClient) { return [] } diff --git a/vscode/src/main.ts b/vscode/src/main.ts index 33f8b62cc68e..5890be0cb064 100644 --- a/vscode/src/main.ts +++ b/vscode/src/main.ts @@ -33,6 +33,7 @@ import { setClientNameVersion, setEditorWindowIsFocused, setLogger, + setOpenCtxControllerObservable, setResolvedConfigurationObservable, startWith, subscriptionDisposable, @@ -79,7 +80,7 @@ import type { CodyCommandArgs } from './commands/types' import { newCodyCommandArgs } from './commands/utils/get-commands' import { createInlineCompletionItemProvider } from './completions/create-inline-completion-item-provider' import { getConfiguration } from './configuration' -import { exposeOpenCtxClient } from './context/openctx' +import { observeOpenCtxController } from './context/openctx' import { logGlobalStateEmissions } from './dev/helpers' import { EditManager } from './edit/manager' import { manageDisplayPathEnvInfoForExtension } from './editor/displayPathEnvInfo' @@ -232,6 +233,8 @@ const register = async ( // Initialize singletons await initializeSingletons(platform, disposables) + setOpenCtxControllerObservable(observeOpenCtxController(context, platform.createOpenCtxController)) + // Ensure Git API is available disposables.push(await initVSCodeGitApi()) @@ -275,14 +278,7 @@ const register = async ( CodyToolProvider.initialize(contextRetriever) - disposables.push( - chatsController, - ghostHintDecorator, - editManager, - subscriptionDisposable( - exposeOpenCtxClient(context, platform.createOpenCtxController).subscribe({}) - ) - ) + disposables.push(chatsController, ghostHintDecorator, editManager) const statusBar = CodyStatusBar.init() disposables.push(statusBar)