From a43ebe44b7015b6acc59d30491cf798ca0ddd8b5 Mon Sep 17 00:00:00 2001 From: Zoe Lin Date: Wed, 9 Apr 2025 17:15:15 -0700 Subject: [PATCH 1/4] feat(amazonq): chat context commands for prompts feat(amazonq): integrate with local context server fix(amazonq): fixes and refactor --- package-lock.json | 161 ++++++++++--- server/aws-lsp-codewhisperer/package.json | 1 + .../agenticChat/agenticChatController.test.ts | 30 ++- .../agenticChat/agenticChatController.ts | 78 ++++-- .../agenticChatEventParser.test.ts | 1 + .../agenticChat/agenticChatEventParser.ts | 11 +- .../context/additionalContextProvider.test.ts | 222 ++++++++++++++++++ .../context/addtionalContextProvider.ts | 154 ++++++++++++ .../context/agenticChatTriggerContext.ts | 132 +++++++++++ .../agenticChatTriggerContexts.test.ts | 94 ++++++++ .../context/contextCommandsProvider.test.ts | 49 ++++ .../context/contextCommandsProvider.ts | 171 ++++++++++++++ .../agenticChat/context/contextUtils.ts | 18 ++ .../agenticChat/qAgenticChatServer.ts | 12 +- .../chat/contexts/documentContext.test.ts | 12 +- .../chat/contexts/documentContext.ts | 6 +- .../localProjectContextServer.ts | 6 +- .../localProjectContextController.test.ts | 29 ++- .../shared/localProjectContextController.ts | 90 ++++++- 19 files changed, 1211 insertions(+), 66 deletions(-) create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.test.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/addtionalContextProvider.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContext.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContexts.test.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.test.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextUtils.ts diff --git a/package-lock.json b/package-lock.json index 8650ba47f6..bbe00c144a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8486,18 +8486,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@wdio/cli/node_modules/readdirp": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@wdio/cli/node_modules/strip-final-newline": { "version": "4.0.0", "dev": true, @@ -10698,18 +10686,13 @@ }, "node_modules/chokidar": { "version": "3.6.0", + "dev": true, "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -10718,16 +10701,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/chrome-trace-event": { "version": "1.0.4", "devOptional": true, @@ -17028,6 +17001,31 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/mocha/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/mocha/node_modules/find-up": { "version": "5.0.0", "dev": true, @@ -17043,6 +17041,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mocha/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/mocha/node_modules/locate-path": { "version": "6.0.0", "dev": true, @@ -17090,6 +17101,19 @@ "node": ">=8" } }, + "node_modules/mocha/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "dev": true, @@ -18817,13 +18841,16 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/real-require": { @@ -22076,6 +22103,54 @@ } } }, + "node_modules/webpack-dev-server/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/webpack-dev-server/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/webpack-dev-server/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/webpack-merge": { "version": "6.0.1", "dev": true, @@ -22810,6 +22885,7 @@ "adm-zip": "^0.5.10", "archiver": "^7.0.1", "aws-sdk": "^2.1403.0", + "chokidar": "^4.0.3", "deepmerge": "^4.3.1", "diff": "^7.0.0", "fastest-levenshtein": "^1.0.16", @@ -22911,6 +22987,21 @@ "node": ">=14.0.0" } }, + "server/aws-lsp-codewhisperer/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "server/aws-lsp-codewhisperer/node_modules/diff": { "version": "7.0.0", "license": "BSD-3-Clause", diff --git a/server/aws-lsp-codewhisperer/package.json b/server/aws-lsp-codewhisperer/package.json index ed64b51b12..b8d5f6fb6e 100644 --- a/server/aws-lsp-codewhisperer/package.json +++ b/server/aws-lsp-codewhisperer/package.json @@ -38,6 +38,7 @@ "adm-zip": "^0.5.10", "archiver": "^7.0.1", "aws-sdk": "^2.1403.0", + "chokidar": "^4.0.3", "deepmerge": "^4.3.1", "diff": "^7.0.0", "fastest-levenshtein": "^1.0.16", diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts index fdd7b4396e..d817798561 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts @@ -3,6 +3,7 @@ * Will be deleted or merged. */ +import * as path from 'path' import { ChatResponseStream, CodeWhispererStreaming, @@ -36,6 +37,8 @@ import { DEFAULT_HELP_FOLLOW_UP_PROMPT, HELP_MESSAGE } from '../chat/constants' import { TelemetryService } from '../../shared/telemetry/telemetryService' import { AmazonQTokenServiceManager } from '../../shared/amazonQServiceManager/AmazonQTokenServiceManager' import { TabBarController } from './tabBarController' +import { getUserPromptsDirectory } from './context/contextUtils' +import { AdditionalContextProvider } from './context/addtionalContextProvider' describe('AgenticChatController', () => { const mockTabId = 'tab-1' @@ -97,11 +100,13 @@ describe('AgenticChatController', () => { let sendMessageStub: sinon.SinonStub let generateAssistantResponseStub: sinon.SinonStub + let additionalContextProviderStub: sinon.SinonStub let disposeStub: sinon.SinonStub let activeTabSpy: { get: sinon.SinonSpy<[], string | undefined> set: sinon.SinonSpy<[string | undefined], void> } + let fsWriteFileStub: sinon.SinonStub let removeConversationSpy: sinon.SinonSpy let emitConversationMetricStub: sinon.SinonStub @@ -144,13 +149,14 @@ describe('AgenticChatController', () => { }) testFeatures = new TestFeatures() + fsWriteFileStub = sinon.stub() testFeatures.workspace.fs = { ...testFeatures.workspace.fs, getServerDataDirPath: sinon.stub().returns('/mock/server/data/path'), mkdir: sinon.stub().resolves(), readFile: sinon.stub().resolves(), - writeFile: sinon.stub().resolves(), + writeFile: fsWriteFileStub.resolves(), rm: sinon.stub().resolves(), } @@ -161,6 +167,8 @@ describe('AgenticChatController', () => { addTool: sinon.stub().resolves(), } + additionalContextProviderStub = sinon.stub(AdditionalContextProvider.prototype, 'getAdditionalContext') + additionalContextProviderStub.resolves([]) // @ts-ignore const cachedInitializeParams: InitializeParams = { initializationOptions: { @@ -173,6 +181,7 @@ describe('AgenticChatController', () => { }, }, } + testFeatures.lsp.window.showDocument = sinon.stub() testFeatures.lsp.getClientInitializeParams.returns(cachedInitializeParams) setCredentials('builderId') @@ -1001,6 +1010,25 @@ describe('AgenticChatController', () => { }) }) + describe('onCreatePrompt', () => { + it('should create prompt file with given name', async () => { + const promptName = 'testPrompt' + const expectedPath = path.join(getUserPromptsDirectory(), 'testPrompt.prompt.md') + + await chatController.onCreatePrompt({ promptName }) + + sinon.assert.calledOnceWithExactly(fsWriteFileStub, expectedPath, '', { mode: 0o600 }) + }) + + it('should create default prompt file when no name provided', async () => { + const expectedPath = path.join(getUserPromptsDirectory(), 'default.prompt.md') + + await chatController.onCreatePrompt({ promptName: '' }) + + sinon.assert.calledOnceWithExactly(fsWriteFileStub, expectedPath, '', { mode: 0o600 }) + }) + }) + describe('onInlineChatPrompt', () => { it('read all the response streams and return compiled results', async () => { const chatResultPromise = chatController.onInlineChatPrompt( diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts index 677c03b695..f7139be70e 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -7,7 +7,9 @@ import { ChatTriggerType, GenerateAssistantResponseCommandInput, GenerateAssistantResponseCommandOutput, + SendMessageCommandInput, SendMessageCommandInput as SendMessageCommandInputCodeWhispererStreaming, + SendMessageCommandOutput, ToolResult, ToolResultContentBlock, ToolUse, @@ -24,10 +26,15 @@ import { ConversationClickParams, ListConversationsParams, TabBarActionParams, + CreatePromptParams, + FileClickParams, +} from '@aws/language-server-runtimes/protocol' +import { CancellationToken, Chat, ChatParams, ChatResult, + FileList, EndChatParams, LSPErrorCodes, QuickActionParams, @@ -52,7 +59,6 @@ import { ChatTelemetryController } from '../chat/telemetry/chatTelemetryControll import { QuickAction } from '../chat/quickActions' import { Metric } from '../../shared/telemetry/metric' import { getErrorMessage, isAwsError, isNullish, isObject } from '../../shared/utils' -import { QChatTriggerContext, TriggerContext } from '../chat/contexts/triggerContext' import { HELP_MESSAGE } from '../chat/constants' import { TelemetryService } from '../../shared/telemetry/telemetryService' import { @@ -63,7 +69,6 @@ import { AmazonQTokenServiceManager } from '../../shared/amazonQServiceManager/A import { AmazonQWorkspaceConfig } from '../../shared/amazonQServiceManager/configurationUtils' import { TabBarController } from './tabBarController' import { ChatDatabase } from './tools/chatDb/chatDb' -import { SendMessageCommandInput, SendMessageCommandOutput } from '../../shared/streamingClientService' import { AgenticChatEventParser, ChatResultWithMetadata as AgenticChatResultWithMetadata, @@ -71,14 +76,22 @@ import { import { ChatSessionService } from '../chat/chatSessionService' import { AgenticChatResultStream } from './agenticChatResultStream' import { executeToolMessage, toolErrorMessage, toolResultMessage } from './textFormatting' +import { + AdditionalContentEntryAddition, + AgenticChatTriggerContext, + TriggerContext, +} from './context/agenticChatTriggerContext' +import { AdditionalContextProvider } from './context/addtionalContextProvider' +import { getNewPromptFilePath } from './context/contextUtils' type ChatHandlers = Omit< LspHandlers, | 'openTab' | 'sendChatUpdate' - | 'onFileClicked' | 'sendContextCommands' - | 'onCreatePrompt' + | 'onListConversations' + | 'onConversationClick' + | 'onTabBarAction' | 'getSerializedChat' | 'chatOptionsUpdate' > @@ -87,12 +100,13 @@ export class AgenticChatController implements ChatHandlers { #features: Features #chatSessionManagementService: ChatSessionManagementService #telemetryController: ChatTelemetryController - #triggerContext: QChatTriggerContext + #triggerContext: AgenticChatTriggerContext #customizationArn?: string #telemetryService: TelemetryService #amazonQServiceManager?: AmazonQTokenServiceManager #tabBarController: TabBarController #chatHistoryDb: ChatDatabase + #additionalContextProvider: AdditionalContextProvider constructor( chatSessionManagementService: ChatSessionManagementService, @@ -102,12 +116,24 @@ export class AgenticChatController implements ChatHandlers { ) { this.#features = features this.#chatSessionManagementService = chatSessionManagementService - this.#triggerContext = new QChatTriggerContext(features.workspace, features.logging) + this.#triggerContext = new AgenticChatTriggerContext(features.workspace, features.logging) this.#telemetryController = new ChatTelemetryController(features, telemetryService) this.#telemetryService = telemetryService this.#amazonQServiceManager = amazonQServiceManager this.#chatHistoryDb = new ChatDatabase(features) this.#tabBarController = new TabBarController(features, this.#chatHistoryDb) + this.#additionalContextProvider = new AdditionalContextProvider(features.workspace) + } + + async onCreatePrompt(params: CreatePromptParams): Promise { + const newFilePath = getNewPromptFilePath(params.promptName) + const newFileContent = '' + try { + await this.#features.workspace.fs.writeFile(newFilePath, newFileContent, { mode: 0o600 }) + await this.#features.lsp.window.showDocument({ uri: newFilePath }) + } catch (e) { + this.#features.logging.warn(`Error creating prompt file: ${e}`) + } } dispose() { @@ -166,8 +192,21 @@ export class AgenticChatController implements ChatHandlers { const conversationIdentifier = session?.conversationId ?? 'New conversation' const chatResultStream = this.#getChatResultStream(params.partialResultToken) try { + const additionalContext = await this.#additionalContextProvider.getAdditionalContext( + triggerContext, + (params.prompt as any).context + ) + if (additionalContext.length) { + triggerContext.documentReference = + this.#additionalContextProvider.getFileListFromContext(additionalContext) + } // Get the initial request input - const initialRequestInput = await this.#prepareRequestInput(params, session, triggerContext) + const initialRequestInput = await this.#prepareRequestInput( + params, + session, + triggerContext, + additionalContext + ) // Start the agent loop const finalResult = await this.#runAgentLoop( @@ -176,7 +215,8 @@ export class AgenticChatController implements ChatHandlers { metric, chatResultStream, conversationIdentifier, - token + token, + triggerContext.documentReference ) // Phase 5: Result Handling - This happens only once @@ -200,7 +240,8 @@ export class AgenticChatController implements ChatHandlers { async #prepareRequestInput( params: ChatParams, session: ChatSessionService, - triggerContext: TriggerContext + triggerContext: TriggerContext, + additionalContext: AdditionalContentEntryAddition[] ): Promise { this.#debug('Preparing request input') const profileArn = AmazonQTokenServiceManager.getInstance(this.#features).getActiveProfileArn() @@ -210,7 +251,8 @@ export class AgenticChatController implements ChatHandlers { ChatTriggerType.MANUAL, this.#customizationArn, profileArn, - this.#features.agent.getTools({ format: 'bedrock' }) + this.#features.agent.getTools({ format: 'bedrock' }), + additionalContext ) if (!session.localHistoryHydrated && requestInput.conversationState) { @@ -230,7 +272,8 @@ export class AgenticChatController implements ChatHandlers { metric: Metric, chatResultStream: AgenticChatResultStream, conversationIdentifier?: string, - token?: CancellationToken + token?: CancellationToken, + documentReference?: FileList ): Promise> { let currentRequestInput = { ...initialRequestInput } let finalResult: Result | null = null @@ -260,7 +303,8 @@ export class AgenticChatController implements ChatHandlers { cwsprChatResponseCode: response.$metadata.httpStatusCode, cwsprChatMessageId: response.$metadata.requestId, }), - chatResultStream + chatResultStream, + documentReference ) // Store the conversation ID from the first response @@ -670,6 +714,11 @@ export class AgenticChatController implements ChatHandlers { return success } + async onFileClicked(params: FileClickParams) { + // TODO: also pass in selection and handle on client side + await this.#features.lsp.window.showDocument({ uri: params.filePath }) + } + onFollowUpClicked() {} onInfoLinkClick() {} @@ -811,13 +860,14 @@ export class AgenticChatController implements ChatHandlers { async #processGenerateAssistantResponseResponse( response: GenerateAssistantResponseCommandOutput, metric: Metric, - chatResultStream: AgenticChatResultStream + chatResultStream: AgenticChatResultStream, + contextList?: FileList ): Promise> { const requestId = response.$metadata.requestId! const chatEventParser = new AgenticChatEventParser(requestId, metric) const streamWriter = chatResultStream.getResultStreamWriter() for await (const chatEvent of response.generateAssistantResponseResponse!) { - const result = chatEventParser.processPartialEvent(chatEvent) + const result = chatEventParser.processPartialEvent(chatEvent, contextList) // terminate early when there is an error if (!result.success) { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatEventParser.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatEventParser.test.ts index 9a3e5f7cde..2a2fe4898d 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatEventParser.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatEventParser.test.ts @@ -34,6 +34,7 @@ describe('AgenticChatEventParser', () => { codeReference: undefined, followUp: undefined, relatedContent: undefined, + contextList: undefined, }, conversationId: undefined, }, diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatEventParser.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatEventParser.ts index 0f69531cc8..2f05e303d1 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatEventParser.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatEventParser.ts @@ -7,6 +7,7 @@ import { ChatResponseStream, Reference, SupplementaryWebLink, ToolUse } from '@a import { ChatItemAction, ChatResult, + FileList, ReferenceTrackerInformation, SourceLink, } from '@aws/language-server-runtimes/protocol' @@ -31,6 +32,7 @@ export class AgenticChatEventParser implements ChatResult { followUp?: { text?: string; options?: ChatItemAction[] } codeReference?: ReferenceTrackerInformation[] toolUses: Record = {} + contextList?: FileList = undefined conversationId?: string @@ -78,7 +80,10 @@ export class AgenticChatEventParser implements ChatResult { return this.#totalEvents } - public processPartialEvent(chatEvent: ChatResponseStream): Result { + public processPartialEvent( + chatEvent: ChatResponseStream, + contextList?: FileList + ): Result { const { messageMetadataEvent, followupPromptEvent, @@ -108,6 +113,9 @@ export class AgenticChatEventParser implements ChatResult { } else if (invalidStateEvent) { this.error = invalidStateEvent.message ?? invalidStateEvent.reason ?? 'Invalid state' } else if (assistantResponseEvent?.content) { + if (contextList?.filePaths?.length) { + this.contextList = contextList + } this.#totalEvents.assistantResponseEvent += 1 this.body = (this.body ?? '') + assistantResponseEvent.content } else if (toolUseEvent) { @@ -185,6 +193,7 @@ export class AgenticChatEventParser implements ChatResult { relatedContent: this.relatedContent, followUp: this.followUp, codeReference: this.codeReference, + ...(this.contextList && { contextList: { ...this.contextList } }), } const chatResultWithMetadata = { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.test.ts new file mode 100644 index 0000000000..3e9b3ef60d --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.test.ts @@ -0,0 +1,222 @@ +import * as path from 'path' +import * as sinon from 'sinon' +import { URI } from 'vscode-uri' +import { TestFeatures } from '@aws/language-server-runtimes/testing' +import * as assert from 'assert' +import { AdditionalContextPrompt } from 'local-indexing' +import { AdditionalContextProvider } from './addtionalContextProvider' +import { getUserPromptsDirectory } from './contextUtils' +import { LocalProjectContextController } from '../../../shared/localProjectContextController' + +describe('AdditionalContextProvider', () => { + let provider: AdditionalContextProvider + let testFeatures: TestFeatures + let fsExistsStub: sinon.SinonStub + let getContextCommandPromptStub: sinon.SinonStub + let fsReadDirStub: sinon.SinonStub + let localProjectContextControllerInstanceStub: sinon.SinonStub + + beforeEach(() => { + testFeatures = new TestFeatures() + fsExistsStub = sinon.stub() + fsReadDirStub = sinon.stub() + testFeatures.workspace.fs.exists = fsExistsStub + testFeatures.workspace.fs.readdir = fsReadDirStub + getContextCommandPromptStub = sinon.stub() + provider = new AdditionalContextProvider(testFeatures.workspace) + localProjectContextControllerInstanceStub = sinon.stub(LocalProjectContextController, 'getInstance').returns({ + getContextCommandPrompt: getContextCommandPromptStub, + getRootDirectory: sinon.stub().resolves('/'), + } as unknown as LocalProjectContextController) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('getAdditionalContext', () => { + it('should return empty array when no additional context commands', async () => { + const triggerContext = { + workspaceFolder: null, + context: [], + workspaceRulesCount: 0, + } + + fsExistsStub.resolves(false) + getContextCommandPromptStub.resolves([]) + + const result = await provider.getAdditionalContext(triggerContext) + + assert.deepStrictEqual(result, []) + }) + + it('should process workspace rules and context correctly', async () => { + const mockWorkspaceFolder = { + uri: URI.file('/workspace').toString(), + name: 'test', + } + const triggerContext = { + workspaceFolder: mockWorkspaceFolder, + context: [], + workspaceRulesCount: 0, + } + + fsExistsStub.resolves(true) + fsReadDirStub.resolves([{ name: 'rule1.prompt.md', isFile: () => true }]) + + getContextCommandPromptStub.resolves([ + { + name: 'Test Rule', + description: 'Test Description', + content: 'Test Content', + filePath: '/workspace/.amazonq/rules/rule1.prompt.md', + relativePath: '.amazonq/rules/rule1.prompt.md', + startLine: 1, + endLine: 10, + }, + ]) + + const result = await provider.getAdditionalContext(triggerContext) + + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].name, 'Test Rule') + assert.strictEqual(result[0].type, 'rule') + }) + }) + + describe('getFileListFromContext', () => { + it('should create correct file list for symbol entries', () => { + const mockContext = [ + { + relativePath: 'test/path.ts', + name: 'symbol', + startLine: 1, + endLine: 10, + type: 'code', + description: 'test', + innerContext: 'test', + }, + ] + + const result = provider.getFileListFromContext(mockContext) + const fileDetail = result.details ? result.details['test/path.ts'] : undefined + const lineRange = fileDetail?.lineRanges ? fileDetail.lineRanges[0] : undefined + + assert.deepStrictEqual(result.filePaths, ['test/path.ts']) + assert.deepStrictEqual(lineRange, { + first: 1, + second: 10, + }) + }) + + it('should handle non-symbol entries with -1 line ranges', () => { + const mockContext = [ + { + relativePath: 'test/path.ts', + name: 'not-symbol', + startLine: 1, + endLine: 10, + type: 'file', + description: 'test', + innerContext: 'test', + }, + ] + + const result = provider.getFileListFromContext(mockContext) + const fileDetail = result.details ? result.details['test/path.ts'] : undefined + const lineRange = fileDetail?.lineRanges ? fileDetail.lineRanges[0] : undefined + + assert.deepStrictEqual(lineRange, { + first: -1, + second: -1, + }) + }) + }) + + describe('getContextType', () => { + const mockPrompt: AdditionalContextPrompt = { + filePath: path.join('/workspace', '.amazonq', 'rules', 'test.prompt.md'), + relativePath: path.join('.amazonq', 'rules', 'test.prompt.md'), + content: 'Sample content', + name: 'Test Rule', + description: 'Test Description', + startLine: 1, + endLine: 10, + } + it('should identify rule type for files in .amazonq/rules', () => { + const result = provider.getContextType(mockPrompt) + + assert.strictEqual(result, 'rule') + }) + + it('should identify prompt type for files in user prompts directory', () => { + const userPromptsDir = getUserPromptsDirectory() + const mockPrompt = { + filePath: path.join(userPromptsDir, 'test.prompt.md'), + relativePath: 'test.prompt.md', + content: 'Sample content', + name: 'Test Prompt', + description: 'Test Description', + startLine: 1, + endLine: 10, + } + + const result = provider.getContextType(mockPrompt) + + assert.strictEqual(result, 'prompt') + }) + + it('should return file type for non-prompt files', () => { + const mockPrompt = { + filePath: 'test.ts', + relativePath: 'test.ts', + content: 'Sample content', + name: 'Test File', + description: 'Test Description', + startLine: 1, + endLine: 10, + } + + const result = provider.getContextType(mockPrompt) + + assert.strictEqual(result, 'file') + }) + }) + + describe('collectWorkspaceRules', () => { + it('should return empty array when no workspace folder', async () => { + const triggerContext = { + relativeFilePath: 'test.ts', + workspaceFolder: null, + } + + const result = await provider.collectWorkspaceRules(triggerContext) + + assert.deepStrictEqual(result, []) + }) + + it('should return rules files when they exist', async () => { + const mockWorkspaceFolder = { + uri: URI.file('/workspace').toString(), + name: 'test', + } + const triggerContext = { + relativeFilePath: 'test.ts', + workspaceFolder: mockWorkspaceFolder, + } + + fsExistsStub.resolves(true) + fsReadDirStub.resolves([ + { name: 'rule1.prompt.md', isFile: () => true }, + { name: 'rule2.prompt.md', isFile: () => true }, + ]) + + const result = await provider.collectWorkspaceRules(triggerContext) + + assert.deepStrictEqual(result, [ + path.join('/workspace', '.amazonq', 'rules', 'rule1.prompt.md'), + path.join('/workspace', '.amazonq', 'rules', 'rule2.prompt.md'), + ]) + }) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/addtionalContextProvider.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/addtionalContextProvider.ts new file mode 100644 index 0000000000..4ff9d45947 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/addtionalContextProvider.ts @@ -0,0 +1,154 @@ +import { FileDetails, QuickActionCommand, FileList } from '@aws/language-server-runtimes/protocol' +import { AdditionalContextPrompt, ContextCommandItem, ContextCommandItemType } from 'local-indexing' +import * as path from 'path' +import { AdditionalContentEntryAddition, TriggerContext } from './agenticChatTriggerContext' +import { URI } from 'vscode-uri' +import { Workspace } from '@aws/language-server-runtimes/server-interface' +import { pathUtils } from '@aws/lsp-core' +import { + additionalContentInnerContextLimit, + additionalContentNameLimit, + getUserPromptsDirectory, + promptFileExtension, +} from './contextUtils' +import { LocalProjectContextController } from '../../../shared/localProjectContextController' + +export class AdditionalContextProvider { + constructor(private readonly workspace: Workspace) {} + + async collectWorkspaceRules(triggerContext: TriggerContext): Promise { + const rulesFiles: string[] = [] + const folder = triggerContext.workspaceFolder + if (!folder) { + return rulesFiles + } + const workspaceRoot = folder.uri + ? URI.parse(folder.uri).fsPath + : LocalProjectContextController.getInstance().getRootDirectory() + const rulesPath = path.join(workspaceRoot, '.amazonq', 'rules') + const folderExists = await this.workspace.fs.exists(rulesPath) + + if (folderExists) { + const entries = await this.workspace.fs.readdir(rulesPath) + + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(promptFileExtension)) { + rulesFiles.push(path.join(rulesPath, entry.name)) + } + } + } + return rulesFiles + } + + getContextType(prompt: AdditionalContextPrompt): string { + if (prompt.filePath.endsWith(promptFileExtension)) { + if (pathUtils.isInDirectory(path.join('.amazonq', 'rules'), prompt.relativePath)) { + return 'rule' + } else if (pathUtils.isInDirectory(getUserPromptsDirectory(), prompt.filePath)) { + return 'prompt' + } + } + return 'file' + } + + async getAdditionalContext( + triggerContext: TriggerContext, + context?: any[] + ): Promise { + const additionalContextCommands: ContextCommandItem[] = [] + const workspaceRules = await this.collectWorkspaceRules(triggerContext) + let workspaceFolderPath = triggerContext.workspaceFolder?.uri + ? URI.parse(triggerContext.workspaceFolder.uri).fsPath + : LocalProjectContextController.getInstance().getRootDirectory() + + if (workspaceRules.length > 0) { + additionalContextCommands.push( + ...workspaceRules.map(file => ({ + workspaceFolder: workspaceFolderPath, + type: 'file' as ContextCommandItemType, + relativePath: path.relative(workspaceFolderPath, file), + id: '', + })) + ) + } + triggerContext.workspaceRulesCount = workspaceRules.length + if (context) { + additionalContextCommands.push(...this.mapToContextCommandItems(context, workspaceFolderPath)) + } + + if (additionalContextCommands.length === 0) { + return [] + } + + const prompts = + await LocalProjectContextController.getInstance().getContextCommandPrompt(additionalContextCommands) + + const contextEntry: AdditionalContentEntryAddition[] = [] + for (const prompt of prompts.slice(0, 20)) { + const contextType = this.getContextType(prompt) + const description = + contextType === 'rule' || contextType === 'prompt' + ? `You must follow the instructions in ${prompt.relativePath}. Below are lines ${prompt.startLine}-${prompt.endLine} of this file:\n` + : prompt.description + + const relativePath = prompt.filePath.startsWith(getUserPromptsDirectory()) + ? path.basename(prompt.filePath) + : path.relative(workspaceFolderPath, prompt.filePath) + + const entry = { + name: prompt.name.substring(0, additionalContentNameLimit), + description: description.substring(0, additionalContentNameLimit), + innerContext: prompt.content.substring(0, additionalContentInnerContextLimit), + type: contextType, + relativePath: relativePath, + startLine: prompt.startLine, + endLine: prompt.endLine, + } + + contextEntry.push(entry) + } + return contextEntry + } + + getFileListFromContext(context: AdditionalContentEntryAddition[]): FileList { + const fileDetails: Record = {} + for (const item of context) { + fileDetails[item.relativePath] = { + lineRanges: [ + { + first: item.name === 'symbol' ? item.startLine : -1, + second: item.name === 'symbol' ? item.endLine : -1, + }, + ], + } + } + const fileList: FileList = { + filePaths: context.map(item => item.relativePath), + details: fileDetails, + } + return fileList + } + + mapToContextCommandItems(context: any[], workspaceFolderPath: string): ContextCommandItem[] { + const contextCommands: ContextCommandItem[] = [] + for (const item of context) { + let itemType: ContextCommandItemType | undefined + if (item.icon === 'file') { + itemType = 'file' + } else if (item.icon === 'folder') { + itemType = 'folder' + } else if (item.icon === 'code-block') { + itemType = 'code' + } + if (itemType) { + contextCommands.push({ + workspaceFolder: workspaceFolderPath, + type: itemType, + relativePath: item.command, + id: item.id, + }) + } + } + return contextCommands + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContext.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContext.ts new file mode 100644 index 0000000000..c13958c379 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContext.ts @@ -0,0 +1,132 @@ +import { TriggerType } from '@aws/chat-client-ui-types' +import { + ChatTriggerType, + UserIntent, + Tool, + ToolResult, + AdditionalContentEntry, + GenerateAssistantResponseCommandInput, +} from '@amzn/codewhisperer-streaming' +import { + BedrockTools, + ChatParams, + CursorState, + InlineChatParams, + QuickActionCommand, + FileList, +} from '@aws/language-server-runtimes/server-interface' +import { Features } from '../../types' +import { DocumentContext, DocumentContextExtractor } from '../../chat/contexts/documentContext' + +export interface TriggerContext extends Partial { + userIntent?: UserIntent + triggerType?: TriggerType + workspaceRulesCount?: number + documentReference?: FileList +} +export type LineInfo = { startLine: number; endLine: number } + +export type AdditionalContentEntryAddition = AdditionalContentEntry & { type: string; relativePath: string } & LineInfo + +export class AgenticChatTriggerContext { + private static readonly DEFAULT_CURSOR_STATE: CursorState = { position: { line: 0, character: 0 } } + + #workspace: Features['workspace'] + #documentContextExtractor: DocumentContextExtractor + + constructor(workspace: Features['workspace'], logger: Features['logging']) { + this.#workspace = workspace + this.#documentContextExtractor = new DocumentContextExtractor({ logger, workspace }) + } + + async getNewTriggerContext(params: ChatParams | InlineChatParams): Promise { + const documentContext: DocumentContext | undefined = await this.extractDocumentContext(params) + + return { + ...documentContext, + userIntent: this.#guessIntentFromPrompt(params.prompt.prompt), + } + } + + getChatParamsFromTrigger( + params: ChatParams | InlineChatParams, + triggerContext: TriggerContext, + chatTriggerType: ChatTriggerType, + customizationArn?: string, + profileArn?: string, + tools: BedrockTools = [], + additionalContent?: AdditionalContentEntryAddition[] + ): GenerateAssistantResponseCommandInput { + const { prompt } = params + + const data: GenerateAssistantResponseCommandInput = { + conversationState: { + chatTriggerType: chatTriggerType, + currentMessage: { + userInputMessage: { + content: prompt.escapedPrompt ?? prompt.prompt, + userInputMessageContext: + triggerContext.cursorState && triggerContext.relativeFilePath + ? { + editorState: { + cursorState: triggerContext.cursorState, + document: { + text: triggerContext.text, + programmingLanguage: triggerContext.programmingLanguage, + relativeFilePath: triggerContext.relativeFilePath, + }, + }, + tools, + additionalContext: additionalContent, + } + : { + tools, + additionalContext: additionalContent, + }, + userIntent: triggerContext.userIntent, + origin: 'IDE', + }, + }, + customizationArn, + }, + profileArn, + } + + return data + } + + // public for testing + async extractDocumentContext( + input: Pick + ): Promise { + const { textDocument: textDocumentIdentifier, cursorState } = input + + const textDocument = + textDocumentIdentifier?.uri && (await this.#workspace.getTextDocument(textDocumentIdentifier.uri)) + + return textDocument + ? this.#documentContextExtractor.extractDocumentContext( + textDocument, + // we want to include a default position if a text document is found so users can still ask questions about the opened file + // the range will be expanded up to the max characters downstream + cursorState?.[0] ?? AgenticChatTriggerContext.DEFAULT_CURSOR_STATE + ) + : undefined + } + + #guessIntentFromPrompt(prompt?: string): UserIntent | undefined { + if (prompt === undefined) { + return undefined + } else if (/^explain/i.test(prompt)) { + return UserIntent.EXPLAIN_CODE_SELECTION + } else if (/^refactor/i.test(prompt)) { + return UserIntent.SUGGEST_ALTERNATE_IMPLEMENTATION + } else if (/^fix/i.test(prompt)) { + return UserIntent.APPLY_COMMON_BEST_PRACTICES + } else if (/^optimize/i.test(prompt)) { + return UserIntent.IMPROVE_CODE + } + + return undefined + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContexts.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContexts.test.ts new file mode 100644 index 0000000000..9d2e22baf1 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContexts.test.ts @@ -0,0 +1,94 @@ +import { TestFeatures } from '@aws/language-server-runtimes/testing' +import assert = require('assert') +import { TextDocument } from 'vscode-languageserver-textdocument' +import sinon = require('sinon') +import { AgenticChatTriggerContext } from './agenticChatTriggerContext' +import { DocumentContext, DocumentContextExtractor } from '../../chat/contexts/documentContext' + +describe('AgenticChatTriggerContext', () => { + let testFeatures: TestFeatures + + const filePath = 'file://test.ts' + const mockTSDocument = TextDocument.create(filePath, 'typescript', 1, '') + const mockDocumentContext: DocumentContext = { + text: '', + programmingLanguage: { languageName: 'typescript' }, + relativeFilePath: 'file://test.ts', + hasCodeSnippet: false, + totalEditorCharacters: 0, + } + + beforeEach(() => { + testFeatures = new TestFeatures() + sinon.stub(DocumentContextExtractor.prototype, 'extractDocumentContext').resolves(mockDocumentContext) + }) + + afterEach(() => { + sinon.restore() + }) + + it('returns null if text document is not defined in params', async () => { + const triggerContext = new AgenticChatTriggerContext(testFeatures.workspace, testFeatures.logging) + + const documentContext = await triggerContext.extractDocumentContext({ + cursorState: [ + { + position: { + line: 5, + character: 0, + }, + }, + ], + textDocument: undefined, + }) + + assert.deepStrictEqual(documentContext, undefined) + }) + + it('returns null if text document is not found', async () => { + const triggerContext = new AgenticChatTriggerContext(testFeatures.workspace, testFeatures.logging) + + const documentContext = await triggerContext.extractDocumentContext({ + cursorState: [ + { + position: { + line: 5, + character: 0, + }, + }, + ], + textDocument: { + uri: filePath, + }, + }) + + assert.deepStrictEqual(documentContext, undefined) + }) + + it('passes default cursor state if no cursor is found', async () => { + const triggerContext = new AgenticChatTriggerContext(testFeatures.workspace, testFeatures.logging) + + const documentContext = await triggerContext.extractDocumentContext({ + cursorState: [], + textDocument: { + uri: filePath, + }, + }) + + assert.deepStrictEqual(documentContext, undefined) + }) + + it('includes cursor state from the parameters and text document if found', async () => { + const triggerContext = new AgenticChatTriggerContext(testFeatures.workspace, testFeatures.logging) + + testFeatures.openDocument(mockTSDocument) + const documentContext = await triggerContext.extractDocumentContext({ + cursorState: [], + textDocument: { + uri: filePath, + }, + }) + + assert.deepStrictEqual(documentContext, mockDocumentContext) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.test.ts new file mode 100644 index 0000000000..18ce5b3f4a --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.test.ts @@ -0,0 +1,49 @@ +import { ContextCommandsProvider } from './contextCommandsProvider' +import * as sinon from 'sinon' +import { TestFeatures } from '@aws/language-server-runtimes/testing' + +describe('ContextCommandsProvider', () => { + let provider: ContextCommandsProvider + let testFeatures: TestFeatures + let fsExistsStub: sinon.SinonStub + let fsReadDirStub: sinon.SinonStub + + beforeEach(() => { + testFeatures = new TestFeatures() + fsExistsStub = sinon.stub() + fsReadDirStub = sinon.stub() + + testFeatures.workspace.fs.exists = fsExistsStub + testFeatures.workspace.fs.readdir = fsReadDirStub + provider = new ContextCommandsProvider(testFeatures.logging, testFeatures.chat, testFeatures.workspace) + sinon.stub(provider, 'registerPromptFileWatcher').resolves() + }) + + afterEach(() => { + sinon.restore() + }) + + describe('getUserPrompts', () => { + it('should return empty commands list when directory does not exist', async () => { + fsExistsStub.resolves(false) + const result = await provider.getUserPromptsCommand() + + sinon.assert.match(result.length, 1) // Only create prompt button + sinon.assert.match(result[0].command, 'Create a new prompt') + }) + + it('should return prompt commands when directory exists with files', async () => { + fsExistsStub.resolves(true) + fsReadDirStub.resolves([ + { name: 'test1.prompt.md', isFile: () => true }, + { name: 'test2.prompt.md', isFile: () => true }, + ]) + + const result = await provider.getUserPromptsCommand() + + sinon.assert.match(result.length, 3) // 2 files + create button + sinon.assert.match(result[0].command, 'test1') + sinon.assert.match(result[1].command, 'test2') + }) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.ts new file mode 100644 index 0000000000..e290b984f7 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.ts @@ -0,0 +1,171 @@ +import * as path from 'path' +import { FSWatcher, watch } from 'chokidar' +import { ContextCommand, ContextCommandGroup } from '@aws/language-server-runtimes/protocol' +import { Disposable } from 'vscode-languageclient/node' +import { Chat, Logging, Workspace } from '@aws/language-server-runtimes/server-interface' +import { getUserPromptsDirectory, promptFileExtension } from './contextUtils' +import { ContextCommandItem } from 'local-indexing' + +export class ContextCommandsProvider implements Disposable { + private promptFileWatcher?: FSWatcher + private cachedContextCommands?: ContextCommandItem[] + constructor( + private readonly logging: Logging, + private readonly chat: Chat, + private readonly workspace: Workspace + ) { + this.registerPromptFileWatcher() + } + + registerPromptFileWatcher() { + this.promptFileWatcher = watch(getUserPromptsDirectory(), { + persistent: true, + interval: 2000, + ignoreInitial: true, + }) + + this.promptFileWatcher.on('add', async () => { + await this.processContextCommandUpdate(this.cachedContextCommands ?? []) + }) + + this.promptFileWatcher.on('unlink', async () => { + await this.processContextCommandUpdate(this.cachedContextCommands ?? []) + }) + } + + async getUserPromptsCommand(): Promise { + const createPromptCommand = { + command: 'Create a new prompt', + id: 'create-saved-prompt', + icon: 'list-add', + } + try { + const userPromptsDirectory = getUserPromptsDirectory() + const directoryExists = await this.workspace.fs.exists(userPromptsDirectory) + if (directoryExists) { + const files = await this.workspace.fs.readdir(userPromptsDirectory) + const systemPromptFiles = files.filter(file => file.name.endsWith(promptFileExtension)) + const promptCommands = systemPromptFiles.map( + file => + ({ + command: path.basename(file.name, promptFileExtension), + icon: 'magic', + label: 'file', + id: 'prompt', + route: [userPromptsDirectory, file.name], + }) as ContextCommand + ) + promptCommands.push(createPromptCommand) + return promptCommands + } else { + return [createPromptCommand] + } + } catch (e) { + this.logging.warn(`Error reading user prompts directory: ${e}`) + return [createPromptCommand] + } + } + + async processContextCommandUpdate(items: ContextCommandItem[]) { + const allItems = await this.mapContextCommandItems(items) + this.chat.sendContextCommands({ contextCommandGroups: allItems }) + this.cachedContextCommands = items + } + + async mapContextCommandItems(items: ContextCommandItem[]): Promise { + const folderCmds: ContextCommand[] = [] + const folderCmdGroup: ContextCommand = { + command: 'Folders', + children: [ + { + groupName: 'Folders', + commands: folderCmds, + }, + ], + description: 'Add all files in a folder to context', + icon: 'folder', + } + + const fileCmds: ContextCommand[] = [] + const fileCmdGroup: ContextCommand = { + command: 'Files', + children: [ + { + groupName: 'Files', + commands: fileCmds, + }, + ], + description: 'Add a file to context', + icon: 'file', + } + + const codeCmds: ContextCommand[] = [] + const codeCmdGroup: ContextCommand = { + command: 'Code', + children: [ + { + groupName: 'Code', + commands: codeCmds, + }, + ], + description: 'Add code to context', + icon: 'code-block', + } + + const promptCmds: ContextCommand[] = [] + const promptCmdGroup: ContextCommand = { + command: 'Prompts', + children: [ + { + groupName: 'Prompts', + commands: promptCmds, + }, + ], + description: 'Add a saved prompt to context', + icon: 'magic', + } + const allCommands = [ + { + commands: [folderCmdGroup, fileCmdGroup, codeCmdGroup, promptCmdGroup], + }, + ] as ContextCommandGroup[] + + for (const item of items) { + const wsFolderName = path.basename(item.workspaceFolder) + let baseItem = { + command: path.basename(item.relativePath), + description: path.join(wsFolderName, item.relativePath), + route: [item.workspaceFolder, item.relativePath], + id: item.id, + } + if (item.type === 'file') { + fileCmds.push({ + ...baseItem, + label: 'file', + icon: 'file', + }) + } else if (item.type === 'folder') { + folderCmds.push({ + ...baseItem, + label: 'folder', + icon: 'folder', + }) + } else if (item.symbol) { + codeCmds.push({ + ...baseItem, + description: `${item.symbol.kind}, ${path.join(wsFolderName, item.relativePath)}, L${item.symbol.range.start.line}-${item.symbol.range.end.line}`, + label: 'code', + icon: 'code-block', + }) + } + } + const userPromptsItem = await this.getUserPromptsCommand() + promptCmds.push(...userPromptsItem) + this.chat.sendContextCommands({ contextCommandGroups: allCommands }) + return allCommands + } + + dispose() { + void this.promptFileWatcher?.close() + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextUtils.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextUtils.ts new file mode 100644 index 0000000000..5dfb134583 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextUtils.ts @@ -0,0 +1,18 @@ +import { getUserHomeDir } from '@aws/lsp-core/out/util/path' +import * as path from 'path' + +export const promptFileExtension = '.prompt.md' +export const additionalContentInnerContextLimit = 8192 +export const additionalContentNameLimit = 1024 + +export const getUserPromptsDirectory = (): string => { + return path.join(getUserHomeDir(), '.aws', 'amazonq', 'prompts') +} + +export const getNewPromptFilePath = (promptName: string): string => { + const userPromptsDirectory = getUserPromptsDirectory() + return path.join( + userPromptsDirectory, + promptName ? `${promptName}${promptFileExtension}` : `default${promptFileExtension}` + ) +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/qAgenticChatServer.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/qAgenticChatServer.ts index edcbae37f0..fe7c6b813e 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/qAgenticChatServer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/qAgenticChatServer.ts @@ -171,9 +171,9 @@ If a file is not open, use the \`fsRead\` tool to read from disk. Use this tool }, Promise.resolve>({})) ) - chat.onChatPrompt((...params) => { + chat.onChatPrompt((params, token) => { logging.log('Received chat prompt') - return chatController.onChatPrompt(...params) + return chatController.onChatPrompt(params, token) }) chat.onInlineChatPrompt((...params) => { @@ -200,6 +200,14 @@ If a file is not open, use the \`fsRead\` tool to read from disk. Use this tool chat.onConversationClick(params => { return chatController.onConversationClick(params) }) + + chat.onCreatePrompt((params) => { + return chatController.onCreatePrompt(params) + }) + + chat.onFileClicked((params) => { + return chatController.onFileClicked(params) + }) chat.onTabBarAction(params => { return chatController.onTabBarAction(params) diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/documentContext.test.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/documentContext.test.ts index cf95a278cb..8fc5a7938f 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/documentContext.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/documentContext.test.ts @@ -9,10 +9,13 @@ describe('DocumentContext', () => { const mockTypescriptCodeBlock = `function test() { console.log('test') }` + + const mockWorkspaceFolder = { + uri: 'file://mock/workspace', + name: 'test', + } const mockWorkspace = { - getWorkspaceFolder: sinon.stub().returns({ - uri: 'file://mock/workspace', - }), + getWorkspaceFolder: sinon.stub().returns(mockWorkspaceFolder), fs: { existsSync: sinon.stub().returns(true), }, @@ -48,6 +51,7 @@ describe('DocumentContext', () => { }, }, }, + workspaceFolder: mockWorkspaceFolder, } const result = await documentContextExtractor.extractDocumentContext(mockTSDocument, { @@ -90,6 +94,7 @@ describe('DocumentContext', () => { }, }, }, + workspaceFolder: mockWorkspaceFolder, } const result = await documentContextExtractor.extractDocumentContext(mockTSDocument, { @@ -131,6 +136,7 @@ describe('DocumentContext', () => { end: { line: 0, character: 19 }, }, }, + workspaceFolder: mockWorkspaceFolder, } const result = await documentContextExtractor.extractDocumentContext(mockDocument, { range: { diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/documentContext.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/documentContext.ts index cd37272cf6..bac94d2587 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/documentContext.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/documentContext.ts @@ -1,5 +1,5 @@ import { EditorState, TextDocument as CwsprTextDocument } from '@amzn/codewhisperer-streaming' -import { CursorState } from '@aws/language-server-runtimes/server-interface' +import { CursorState, WorkspaceFolder } from '@aws/language-server-runtimes/server-interface' import { Range, TextDocument } from 'vscode-languageserver-textdocument' import { getLanguageId } from '../../../shared/languageDetection' import { Features } from '../../types' @@ -11,6 +11,7 @@ export type DocumentContext = CwsprTextDocument & { cursorState?: EditorState['cursorState'] hasCodeSnippet: boolean totalEditorCharacters: number + workspaceFolder?: WorkspaceFolder | null } export interface DocumentContextExtractorConfig { @@ -49,6 +50,8 @@ export class DocumentContextExtractor { const rangeWithinCodeBlock = getSelectionWithinExtendedRange(targetRange, codeBlockRange) + const workspaceFolder = this.#workspace?.getWorkspaceFolder?.(document.uri) + const relativePath = this.getRelativePath(document) const languageId = getLanguageId(document) @@ -60,6 +63,7 @@ export class DocumentContextExtractor { relativeFilePath: relativePath, hasCodeSnippet: Boolean(rangeWithinCodeBlock), totalEditorCharacters: document.getText().length, + workspaceFolder, } } diff --git a/server/aws-lsp-codewhisperer/src/language-server/localProjectContext/localProjectContextServer.ts b/server/aws-lsp-codewhisperer/src/language-server/localProjectContext/localProjectContextServer.ts index cb0d124260..787bf3fac9 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/localProjectContext/localProjectContextServer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/localProjectContext/localProjectContextServer.ts @@ -6,7 +6,7 @@ import { languageByExtension } from '../../shared/languageDetection' import { AmazonQWorkspaceConfig } from '../../shared/amazonQServiceManager/configurationUtils' export const LocalProjectContextServer = (): Server => features => { - const { credentialsProvider, telemetry, logging, lsp } = features + const { credentialsProvider, telemetry, logging, lsp, chat, workspace } = features let localProjectContextController: LocalProjectContextController let amazonQServiceManager: AmazonQTokenServiceManager @@ -19,7 +19,9 @@ export const LocalProjectContextServer = (): Server => features => { localProjectContextController = new LocalProjectContextController( params.clientInfo?.name ?? 'unknown', params.workspaceFolders ?? [], - logging + logging, + chat, + workspace ) const supportedFilePatterns = Object.keys(languageByExtension).map(ext => `**/*${ext}`) diff --git a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.test.ts b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.test.ts index 5f13540a6e..fc85697eb5 100644 --- a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.test.ts @@ -1,10 +1,11 @@ import { LocalProjectContextController } from './localProjectContextController' -import { SinonStub, stub, spy, assert as sinonAssert, match } from 'sinon' +import { SinonStub, stub, assert as sinonAssert, match } from 'sinon' import * as assert from 'assert' import * as fs from 'fs' import { Dirent } from 'fs' import * as path from 'path' import { URI } from 'vscode-uri' +import { TestFeatures } from '@aws/language-server-runtimes/testing' class LoggingMock { public error: SinonStub @@ -26,10 +27,12 @@ describe('LocalProjectContextController', () => { let mockWorkspaceFolders: any[] let vectorLibMock: any let fsStub: SinonStub + let testFeatures: TestFeatures const BASE_PATH = path.join(__dirname, 'path', 'to', 'workspace1') beforeEach(() => { + testFeatures = new TestFeatures() logging = new LoggingMock() mockWorkspaceFolders = [ { @@ -45,6 +48,9 @@ describe('LocalProjectContextController', () => { queryVectorIndex: stub().resolves(['mockChunk1', 'mockChunk2']), queryInlineProjectContext: stub().resolves(['mockContext1']), updateIndexV2: stub().resolves(), + getContextCommandItems: stub().resolves([]), + getIndexSequenceNumber: stub().resolves(1), + getContextCommandPrompt: stub().resolves([]), }), } @@ -58,7 +64,14 @@ describe('LocalProjectContextController', () => { return Promise.resolve([]) } }) - controller = new LocalProjectContextController('testClient', mockWorkspaceFolders, logging as any) + + controller = new LocalProjectContextController( + 'testClient', + mockWorkspaceFolders, + logging as any, + testFeatures.chat, + testFeatures.workspace + ) }) afterEach(() => { @@ -93,7 +106,9 @@ describe('LocalProjectContextController', () => { const uninitializedController = new LocalProjectContextController( 'testClient', mockWorkspaceFolders, - logging as any + logging as any, + testFeatures.chat, + testFeatures.workspace ) const result = await uninitializedController.queryVectorIndex({ query: 'test' }) @@ -124,7 +139,9 @@ describe('LocalProjectContextController', () => { const uninitializedController = new LocalProjectContextController( 'testClient', mockWorkspaceFolders, - logging as any + logging as any, + testFeatures.chat, + testFeatures.workspace ) const result = await uninitializedController.queryInlineProjectContext({ @@ -167,7 +184,9 @@ describe('LocalProjectContextController', () => { const uninitializedController = new LocalProjectContextController( 'testClient', mockWorkspaceFolders, - logging as any + logging as any, + testFeatures.chat, + testFeatures.workspace ) await uninitializedController.updateIndex(['test.java'], 'add') diff --git a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts index 50ca4d260c..4e1ae5a292 100644 --- a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts +++ b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts @@ -1,8 +1,10 @@ -import { Logging, WorkspaceFolder } from '@aws/language-server-runtimes/server-interface' +import { Chat, Logging, Workspace, WorkspaceFolder } from '@aws/language-server-runtimes/server-interface' import { dirname } from 'path' import { languageByExtension } from './languageDetection' import type { + AdditionalContextPrompt, Chunk, + ContextCommandItem, InlineProjectContext, QueryInlineProjectContextRequestV2, QueryRequest, @@ -10,6 +12,8 @@ import type { VectorLibAPI, } from 'local-indexing' import { URI } from 'vscode-uri' +import { waitUntil } from '@aws/lsp-core/out/util/timeoutUtils' +import { ContextCommandsProvider } from '../language-server/agenticChat/context/contextCommandsProvider' const fs = require('fs').promises const path = require('path') @@ -25,15 +29,24 @@ export class LocalProjectContextController { private workspaceFolders: WorkspaceFolder[] private _vecLib?: VectorLibAPI + private _contextCommandSymbolsUpdated = false + private readonly contextCommandsProvider: ContextCommandsProvider private readonly fileExtensions: string[] private readonly clientName: string private readonly log: Logging - constructor(clientName: string, workspaceFolders: WorkspaceFolder[], logging: Logging) { + constructor( + clientName: string, + workspaceFolders: WorkspaceFolder[], + logging: Logging, + chat: Chat, + workspace: Workspace + ) { this.fileExtensions = Object.keys(languageByExtension) this.workspaceFolders = workspaceFolders this.clientName = clientName this.log = logging + this.contextCommandsProvider = new ContextCommandsProvider(logging, chat, workspace) } public static getInstance() { @@ -52,6 +65,10 @@ export class LocalProjectContextController { this._vecLib = await vecLib.start(LIBRARY_DIR, this.clientName, root) await this.buildIndex() LocalProjectContextController.instance = this + + const contextItems = await this.getContextCommandItems() + await this.contextCommandsProvider.processContextCommandUpdate(contextItems) + void this.maybeUpdateCodeSymbols() } else { this.log.warn(`Vector library could not be imported from: ${libraryPath}`) } @@ -67,6 +84,10 @@ export class LocalProjectContextController { } } + public getRootDirectory() { + return this.findCommonWorkspaceRoot(this.workspaceFolders) + } + public async updateIndex(filePaths: string[], operation: UpdateMode): Promise { if (!this._vecLib) { return @@ -141,6 +162,71 @@ export class LocalProjectContextController { } } + public async getContextCommandItems(): Promise { + if (!this._vecLib) { + return [] + } + + try { + const foldersPath = this.workspaceFolders.map(folder => URI.parse(folder.uri).fsPath) + const resp = await this._vecLib?.getContextCommandItems(foldersPath) + this.log.log(`received ${resp.length} context command items`) + return resp ?? [] + } catch (error) { + this.log.error(`Error in getContextCommandItems: ${error}`) + return [] + } + } + + public async shouldUpdateContextCommandSymbolsOnce(): Promise { + if (this._contextCommandSymbolsUpdated) { + return false + } + this._contextCommandSymbolsUpdated = true + try { + const indexSeqNum = await this._vecLib?.getIndexSequenceNumber() + await this.updateIndex([], 'context_command_symbol_update') + await waitUntil( + async () => { + const newIndexSeqNum = await this._vecLib?.getIndexSequenceNumber() + if (newIndexSeqNum && indexSeqNum && newIndexSeqNum > indexSeqNum) { + return true + } + return false + }, + { interval: 1000, timeout: 60_000, truthy: true } + ) + return true + } catch (error) { + this.log.error(`Error in shouldUpdateContextCommandSymbolsOnce: ${error}`) + return false + } + } + + public async getContextCommandPrompt( + contextCommandItems: ContextCommandItem[] + ): Promise { + if (!this._vecLib) { + return [] + } + + try { + const resp = await this._vecLib?.getContextCommandPrompt(contextCommandItems) + return resp ?? [] + } catch (error) { + this.log.error(`Error in getContextCommandPrompt: ${error}`) + return [] + } + } + + private async maybeUpdateCodeSymbols() { + const needUpdate = await LocalProjectContextController.getInstance().shouldUpdateContextCommandSymbolsOnce() + if (needUpdate) { + const items = await this.getContextCommandItems() + await this.contextCommandsProvider.processContextCommandUpdate(items) + } + } + private async processWorkspaceFolders(workspaceFolders?: WorkspaceFolder[] | null): Promise { const workspaceSourceFiles: string[] = [] if (workspaceFolders) { From 98aa254f0541b7d508106b9c063370c065278475 Mon Sep 17 00:00:00 2001 From: Zoe Lin Date: Thu, 17 Apr 2025 16:26:34 -0700 Subject: [PATCH 2/4] fix(amazonq): pr comments --- .../context/addtionalContextProvider.ts | 15 +++++++++------ .../context/agenticChatTriggerContext.ts | 5 +++++ .../context/agenticChatTriggerContexts.test.ts | 5 +++++ .../context/contextCommandsProvider.ts | 1 - 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/addtionalContextProvider.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/addtionalContextProvider.ts index 4ff9d45947..ad41de23c7 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/addtionalContextProvider.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/addtionalContextProvider.ts @@ -63,12 +63,15 @@ export class AdditionalContextProvider { if (workspaceRules.length > 0) { additionalContextCommands.push( - ...workspaceRules.map(file => ({ - workspaceFolder: workspaceFolderPath, - type: 'file' as ContextCommandItemType, - relativePath: path.relative(workspaceFolderPath, file), - id: '', - })) + ...workspaceRules.map( + file => + ({ + workspaceFolder: workspaceFolderPath, + type: 'file', + relativePath: path.relative(workspaceFolderPath, file), + id: '', + }) as ContextCommandItem + ) ) } triggerContext.workspaceRulesCount = workspaceRules.length diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContext.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContext.ts index c13958c379..2ce227c3e1 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContext.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContext.ts @@ -1,3 +1,8 @@ +/** + * Copied from chat/contexts/triggerContext.ts for the purpose of developing a divergent implementation. + * Will be deleted or merged. + */ + import { TriggerType } from '@aws/chat-client-ui-types' import { ChatTriggerType, diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContexts.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContexts.test.ts index 9d2e22baf1..fec4a637cb 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContexts.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/agenticChatTriggerContexts.test.ts @@ -1,3 +1,8 @@ +/** + * Copied from chat/contexts/triggerContext.test.ts for the purpose of developing a divergent implementation. + * Will be deleted or merged. + */ + import { TestFeatures } from '@aws/language-server-runtimes/testing' import assert = require('assert') import { TextDocument } from 'vscode-languageserver-textdocument' diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.ts index e290b984f7..72cce548c6 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.ts @@ -161,7 +161,6 @@ export class ContextCommandsProvider implements Disposable { } const userPromptsItem = await this.getUserPromptsCommand() promptCmds.push(...userPromptsItem) - this.chat.sendContextCommands({ contextCommandGroups: allCommands }) return allCommands } From 84b537e371792590d0f9c791c2c2a2a2bab40dd2 Mon Sep 17 00:00:00 2001 From: Zoe Lin Date: Thu, 17 Apr 2025 16:31:11 -0700 Subject: [PATCH 3/4] fix(amazonq): dispose contextCommandProvider --- .../src/shared/localProjectContextController.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts index 4e1ae5a292..823b2046e0 100644 --- a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts +++ b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts @@ -82,6 +82,7 @@ export class LocalProjectContextController { await this._vecLib?.clear?.() this._vecLib = undefined } + this.contextCommandsProvider?.dispose() } public getRootDirectory() { From 6f665932767608824f5bcc22d02df772187a82d8 Mon Sep 17 00:00:00 2001 From: Zoe Lin Date: Thu, 17 Apr 2025 18:19:10 -0700 Subject: [PATCH 4/4] fix(amazonq): fix test --- .../agenticChat/context/contextCommandsProvider.test.ts | 5 +++++ .../src/shared/localProjectContextController.test.ts | 9 ++++++++- .../src/shared/localProjectContextController.ts | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.test.ts index 18ce5b3f4a..53760268bd 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.test.ts @@ -1,6 +1,7 @@ import { ContextCommandsProvider } from './contextCommandsProvider' import * as sinon from 'sinon' import { TestFeatures } from '@aws/language-server-runtimes/testing' +import * as chokidar from 'chokidar' describe('ContextCommandsProvider', () => { let provider: ContextCommandsProvider @@ -9,6 +10,10 @@ describe('ContextCommandsProvider', () => { let fsReadDirStub: sinon.SinonStub beforeEach(() => { + sinon.stub(chokidar, 'watch').returns({ + on: sinon.stub(), + close: sinon.stub(), + } as unknown as chokidar.FSWatcher) testFeatures = new TestFeatures() fsExistsStub = sinon.stub() fsReadDirStub = sinon.stub() diff --git a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.test.ts b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.test.ts index eea7c1dd25..36cb4fbce8 100644 --- a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.test.ts @@ -1,11 +1,12 @@ import { LocalProjectContextController } from './localProjectContextController' -import { SinonStub, stub, assert as sinonAssert, match } from 'sinon' +import { SinonStub, stub, assert as sinonAssert, match, restore } from 'sinon' import * as assert from 'assert' import * as fs from 'fs' import { Dirent } from 'fs' import * as path from 'path' import { URI } from 'vscode-uri' import { TestFeatures } from '@aws/language-server-runtimes/testing' +import * as chokidar from 'chokidar' class LoggingMock { public error: SinonStub @@ -40,6 +41,10 @@ describe('LocalProjectContextController', () => { name: 'workspace1', }, ] + stub(chokidar, 'watch').returns({ + on: stub(), + close: stub(), + } as unknown as chokidar.FSWatcher) vectorLibMock = { start: stub().resolves({ @@ -72,10 +77,12 @@ describe('LocalProjectContextController', () => { testFeatures.chat, testFeatures.workspace ) + stub(controller, 'maybeUpdateCodeSymbols').resolves() }) afterEach(() => { fsStub.restore() + restore() }) describe('init', () => { diff --git a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts index 4e6479d5bd..f4f5538251 100644 --- a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts +++ b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts @@ -269,7 +269,7 @@ export class LocalProjectContextController { } } - private async maybeUpdateCodeSymbols() { + async maybeUpdateCodeSymbols() { const needUpdate = await LocalProjectContextController.getInstance().shouldUpdateContextCommandSymbolsOnce() if (needUpdate) { const items = await this.getContextCommandItems()