From ae7d3fcd26f57d6cc5d3d26dd5ec79983c4103df Mon Sep 17 00:00:00 2001 From: chungjac Date: Tue, 21 Apr 2026 14:01:04 -0700 Subject: [PATCH 1/9] fix: deprecate @workspace vector search + fix @folder files not appearing in context (#2698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove @workspace from context commands menu (#2669) * fix: clean up @workspace dead code after removing from context menu (#2670) * fix: remove @workspace from context commands menu * fix: clean up @workspace dead code after removing from context menu * fix: remove onIndexingInProgressChanged test * chore: remove remaining @workspace / vector search dead code (#2687) Remove all code paths related to @workspace (vector/semantic search) that were left after the initial UI removal. This includes: - localProjectContextController: remove queryVectorIndex, isIndexingEnabled, enableIndexing, GPU/worker thread/cache dir config, onIndexingInProgressChanged, buildIndex('all'). buildIndex now always uses 'default' (repomap only). - configurationUtils: remove enableLocalIndexing, enableGpuAcceleration, indexWorkerThreads, indexCacheDirPath from config types and defaults - localProjectContextServer: remove dead config params passed to init() - triggerContext: remove extractProjectContext and @workspace check - chatTelemetryController/telemetryService: remove cwsprChatHasWorkspaceContext - Delete codeSearch.ts (never registered in toolServer, always dead code) - Update corresponding test files * fix: typed `@folder` context not showing files in collapsible list + fix merge conflicts (#2696) * perf(amazonq): context command performance (#2682) * perf(amazonq): cap context command payload and throttle indexing updates - Cap context commands sent to webview at 10,000 items - Throttle onIndexingInProgressChanged with 500ms coalescing - Cache full item list before applying cap for reuse - Add preservation property-based tests - Update unit tests for throttle behavior * chore: error * fix: newly added files are not be loaded * fix: bugfix * perf: handle context commands in server * perf: add server-side filtering for context commands in large repos * chore: bump language-server-runtimes, runtimes-types, and mynah-ui * chore: remove redundant debug log * fix: filter out externally deleted files from context command results Files deleted outside the IDE (e.g. git revert/checkout) were not removed from the cached context commands because LSP workspace file operation events only fire for IDE-initiated deletions. Add an fs.existsSync check when returning results to the client so stale entries are excluded regardless of how the file was removed. * chore: remove debug logs from context commands and indexing paths * fix: scope filterContextCommandsResponse to requesting tab Previously, filter responses updated contextCommands in all tabs, causing a search in one tab to overwrite the default list in others. Track the originating tabId and scope the store update accordingly. * fix: address PR review feedback for context command filtering * fix: correct URI mapping in onDidRenameFiles handler (#2688) * chore(release): release packages from branch main (#2689) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore: merge agentic version 1.64.0 (#2694) * chore: bump agentic version: 1.64.0 * fix: reserve folder budget in initial context command cap (#2693) * fix: reserve folder budget in initial context command cap The initial sendContextCommands push is capped at 1000 items, but the flat slice(0, 1000) starves folders when files dominate the list. In large repos (212k+ items), the first 1000 are almost all files, so @Folder shows no children until the user types a search term. Partition items by type and reserve 10% of the cap budget for folders before filling the rest with files and code symbols. This ensures @Folder always has children on first load. The filter handler already searches the full uncapped cache, so this only affects the initial push. * test: add folder budget tests for processContextCommandUpdate Verify that the initial context command cap reserves slots for folders: - folders are included when items exceed the cap - all folders included when fewer than budget - total items don't exceed CONTEXT_COMMAND_PAYLOAD_CAP - small payloads pass through unchanged * fix: apply cap to empty-search filter response to preserve @Folder When onFilterContextCommands fires with an empty searchTerm (user navigated back or cleared search), the handler was returning ALL cached items (212k+) with no cap. This massive response overwrote the tab's contextCommands store, and subsequent @Folder clicks showed an empty list because the store structure was inconsistent. Apply the same cap+budget logic as processContextCommandUpdate so the empty-search response matches the initial push structure. * refactor: remove stale context command cache, always pull fresh from indexer The cachedContextCommands field was a separate copy of the indexer's data that could get out of sync — causing @Folder to show empty after searches overwrote the store, and stale items to persist after file operations. Remove the cache entirely. The indexer (local-indexing) is the single source of truth. The filter handler now calls getFreshItems() on every request, and processContextCommandUpdate receives items directly from the indexer callbacks. The cap+budget logic is extracted into capItems() and shared between the initial push and the empty-search filter path. * fix: restore base context commands on empty filter instead of round-tripping After a search filtered the context commands, the store held the filtered set. Subsequent @Folder/@File clicks read from this stale store and showed only the previous search results. When onContextCommandFilter fires with an empty searchTerm (user cleared search or navigated back), restore contextCommandGroups directly to the store instead of sending a request to the server. This keeps the store consistent with the base set and avoids the round-trip latency. * fix: prevent sendContextCommands from resetting active filter tab sendContextCommands is a server push that fires on indexing changes and overwrites contextCommands for ALL tabs. This reset the picker while the user was browsing @Folder/@File sub-menus, causing the 6-10 second snap-back to the main menu. Skip the store update for tabs with an active filter session (lastFilterTabId). Clear the guard on tab change, tab remove, and chat prompt submission so it doesn't persist. * fix: restore base contextCommands after filter response via microtask filterContextCommandsResponse updates the store with filtered results so the picker's store listener can refresh. But this left the store with stale filtered data, causing @Folder/@File to show previous search results on subsequent navigation. After updating the store for the picker, schedule a microtask to restore contextCommandGroups (the base set) back to the store. The picker captures filtered items synchronously during updateStore, so the microtask restore doesn't affect the current display but ensures sub-menu navigation reads from the full base set. * fix: restore filterContextCommandsResponse store update Now that mynah-ui preserves baseContextCommands separately from the store's contextCommands, the filter response can safely update the store again. The picker uses the filtered data for display while sub-menu navigation reads from the base snapshot. * chore: patch * test: cover getFreshItems and registerFilterHandler empty-search - 3 tests for getFreshItems: getInstance reject, getContextCommandItems reject, success path. - 2 tests for registerFilterHandler empty-search path: applies capItems folder budget when called with empty searchTerm and when called with a whitespace-only searchTerm. * fix: reserve code symbol budget in capItems The previous capItems partitioned items into folders vs nonFolders, where nonFolders included both files AND code symbols sharing the same 900-slot budget. In file-heavy repos (e.g. Linux kernel: 212k+ items) files dominate the input order so code symbols are silently dropped from the initial picker view, even though typing a search term still finds them via the non-empty filter path. Replace the 2-way partition with a 3-way 10/10/80 split (folders / code / files). Slack from an under-filled folder or code budget flows into the file budget via the subtraction below. Mirrors the existing folder-budget fix pattern. Add 5 tests: - code symbols included when items exceed cap - all code symbols preserved when fewer than budget - 100/100/800 split when all three categories overflow - code not starved when files come first in input (regression case) - empty-search filter handler also reserves the code budget * fix: bump context command payload cap from 1000 to 2000 Double both CONTEXT_COMMAND_PAYLOAD_CAP and MAX_FILTER_RESULTS so the initial picker view and the typed-search filter response can return up to 2000 items each. The 10/10/80 budget split now yields 200 folders / 200 code / 1600 files instead of 100 / 100 / 800. The bottleneck under load is fs.existsSync over the full ~212k indexer item set, not the cap; doubling the cap adds <50KB to the LSP payload and a few ms to map/render but is otherwise negligible. mynah-ui's DetailedListWrapper virtualizes by visible block, so 2x items don't add proportional render cost. Update all 8 affected test assertions to the new expected counts. * chore: bump @aws/mynah-ui to ^4.40.1 Pulls in the latest mynah-ui patch release. See https://github.com/aws/mynah-ui/releases/tag/v4.40.1 * fix: switch capItems split to 500/500/1000 (25/25/50) Folders and code symbols each get 25% of the cap (500), files get 50% (1000). Previously the 10/10/80 split (200/200/1600) tilted heavily toward files; the new split gives folders and code symbols a fair share of the initial picker view in folder- and symbol-rich repos. This only affects the **empty-search** picker view (no search term). The non-empty filter path still scores against the full fresh indexer set in registerFilterHandler — typing a search term will find any folder, file, or code symbol regardless of whether it fit into the cap. Test inputs scaled up to 600-800 per category so the new 500-slot budget is actually exercised. All 24 tests pass. * test: drop stale cachedContextCommands assertion in preservation test The property-based test 'processContextCommandUpdate sends all items and caches them for small payloads' has been failing in CI since commit 79e6e7594 (refactor: remove stale context command cache, always pull fresh from indexer). That refactor deleted the cachedContextCommands field, but this preservation test still asserted that (provider as any).cachedContextCommands === items, which now always evaluates to undefined !== items and fails on the empty-array counterexample. Drop the cache assertion. The test now verifies the still-meaningful contract: processContextCommandUpdate dispatches exactly one sendContextCommands call with a contextCommandGroups payload. Local repro: npx mocha --require ts-node/register 'src/language-server/agenticChat/context/contextCommandsProvider*.test.ts' → 28 passing. --------- Co-authored-by: aws-toolkit-automation <> * fix: typed @folder context not showing files in collapsible list When typing @folder (non-pinned), the expanded file entries were not appearing in the context list. The ordering logic looked up the folder path in docMap, but docMap is keyed by individual file paths. Added a fallback for folder items that matches all child file entries by path prefix. Pinned @folder was unaffected because it bypasses the docMap lookup. * chore: fix prettier formatting --------- Co-authored-by: Will Lo <96078566+Will-ShaoHua@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fix: rebuild qserver zips with stripped indexing library (#2672) Remove ONNX, faiss, and CodeSage model from qserver bundles. The indexing library no longer contains vector/semantic search code since @workspace is being removed. Each qserver zip drops from ~100MB to ~2.9MB. @file, @folder, @code continue to work (tree-sitter + BM25). All 5 platform zips are now identical (no native binaries). --------- Co-authored-by: Will Lo <96078566+Will-ShaoHua@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: aws-toolkit-automation <43144436+aws-toolkit-automation@users.noreply.github.com> --- .../_bundle-assets/qserver-darwin-arm64.zip | 4 +- .../_bundle-assets/qserver-darwin-x64.zip | 4 +- .../_bundle-assets/qserver-linux-arm64.zip | 4 +- .../_bundle-assets/qserver-linux-x64.zip | 4 +- .../_bundle-assets/qserver-win32-x64.zip | 4 +- .../agenticChat/agenticChatController.test.ts | 60 +----- .../agenticChat/agenticChatController.ts | 1 - .../context/additionalContextProvider.test.ts | 21 -- .../context/additionalContextProvider.ts | 18 +- .../context/agenticChatTriggerContext.ts | 99 +-------- ...ntextCommandsProvider.preservation.test.ts | 1 - .../context/contextCommandsProvider.test.ts | 37 +--- .../context/contextCommandsProvider.ts | 44 +--- .../agenticChat/tools/codeSearch.test.ts | 171 --------------- .../agenticChat/tools/codeSearch.ts | 194 ------------------ .../chat/contexts/triggerContext.ts | 39 +--- .../chat/contexts/triggerContexts.test.ts | 22 -- .../chat/telemetry/chatTelemetryController.ts | 1 - .../localProjectContextServer.ts | 10 - .../configurationUtils.test.ts | 17 +- .../configurationUtils.ts | 12 -- .../localProjectContextController.test.ts | 147 +------------ .../shared/localProjectContextController.ts | 88 +------- .../shared/telemetry/telemetryService.test.ts | 2 - .../src/shared/telemetry/telemetryService.ts | 2 - 25 files changed, 49 insertions(+), 957 deletions(-) delete mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/codeSearch.test.ts delete mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/codeSearch.ts diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-arm64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-arm64.zip index 6f0038c9d7..8e6a8bbb8f 100644 --- a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-arm64.zip +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-arm64.zip @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09b75b788854e2c2f08b9fa73c671e476f7e20b8284521f544ea7f2e2c82d3fa -size 96549602 +oid sha256:f59a63572dbadb648fe60741b41d929cbd2735a72312fedd07dc37bf9b9a78e8 +size 3080924 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-x64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-x64.zip index 709c9d1052..8e6a8bbb8f 100644 --- a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-x64.zip +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-darwin-x64.zip @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f13048f6989d01f8a5b8d9743ca2efa023cc4ae0c05efcd4fc0cb22f4b2dd5c3 -size 98233434 +oid sha256:f59a63572dbadb648fe60741b41d929cbd2735a72312fedd07dc37bf9b9a78e8 +size 3080924 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-arm64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-arm64.zip index d47ede8677..8e6a8bbb8f 100644 --- a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-arm64.zip +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-arm64.zip @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e119ae06538b7bfe7ce0050d88909c64989b10c481477e24bdd6ab9f6152846 -size 102483123 +oid sha256:f59a63572dbadb648fe60741b41d929cbd2735a72312fedd07dc37bf9b9a78e8 +size 3080924 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-x64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-x64.zip index 5aeec68248..8e6a8bbb8f 100644 --- a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-x64.zip +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-linux-x64.zip @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8aea05af87c620a7be4cb58b4b9b1a579e5726b1eb3682e55c42302ff19d853d -size 114470426 +oid sha256:f59a63572dbadb648fe60741b41d929cbd2735a72312fedd07dc37bf9b9a78e8 +size 3080924 diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-win32-x64.zip b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-win32-x64.zip index 1d3937e552..8e6a8bbb8f 100644 --- a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-win32-x64.zip +++ b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/qserver-win32-x64.zip @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aafb3ef97fca6ba0369f7bfc48b5846e2b4f4fdec0014aae58be70f49cc42116 -size 113755807 +oid sha256:f59a63572dbadb648fe60741b41d929cbd2735a72312fedd07dc37bf9b9a78e8 +size 3080924 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 fe32f4402c..a6bcc00404 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 @@ -257,11 +257,7 @@ describe('AgenticChatController', () => { } as any // Using 'as any' to prevent type errors when the Agent interface is updated with new methods additionalContextProviderStub = sinon.stub(AdditionalContextProvider.prototype, 'getAdditionalContext') - additionalContextProviderStub.callsFake(async (triggerContext, _, context: ContextCommand[]) => { - // When @workspace is in the context, set hasWorkspace flag - if (context && context.some(item => item.command === '@workspace')) { - triggerContext.hasWorkspace = true - } + additionalContextProviderStub.callsFake(async () => { return [] }) // @ts-ignore @@ -1415,60 +1411,6 @@ describe('AgenticChatController', () => { extractDocumentContextStub.restore() }) - it('parses relevant document and includes as requestInput if @workspace context is included', async () => { - const localProjectContextController = new LocalProjectContextController('client-name', [], logging) - const mockRelevantDocs = [ - { filePath: '/test/1.ts', content: 'text', id: 'id-1', index: 0, vec: [1] }, - { filePath: '/test/2.ts', content: 'text2', id: 'id-2', index: 0, vec: [1] }, - ] - - sinon.stub(LocalProjectContextController, 'getInstance').resolves(localProjectContextController) - sinon.stub(localProjectContextController, 'isIndexingEnabled').returns(true) - sinon.stub(localProjectContextController, 'queryVectorIndex').resolves(mockRelevantDocs) - - await chatController.onChatPrompt( - { - tabId: 'tab', - prompt: { - prompt: '@workspace help me understand this code', - escapedPrompt: '@workspace help me understand this code', - }, - context: [{ command: '@workspace' }], - }, - mockCancellationToken - ) - - const calledRequestInput: GenerateAssistantResponseCommandInput = - generateAssistantResponseStub.firstCall.firstArg - - assert.deepStrictEqual( - calledRequestInput.conversationState?.currentMessage?.userInputMessage?.userInputMessageContext - ?.editorState, - { - workspaceFolders: [], - relevantDocuments: [ - { - endLine: -1, - path: '/test/1.ts', - relativeFilePath: '1.ts', - startLine: -1, - text: 'text', - type: ContentType.WORKSPACE, - }, - { - endLine: -1, - path: '/test/2.ts', - relativeFilePath: '2.ts', - startLine: -1, - text: 'text2', - type: ContentType.WORKSPACE, - }, - ], - useRelevantDocuments: true, - } - ) - }) - it('leaves cursorState as undefined if cursorState is not passed', async () => { const documentContextObject = { programmingLanguage: 'typescript', 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 ebd415e5af..a3384faf17 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -3537,7 +3537,6 @@ export class AgenticChatController implements ChatHandlers { if (triggerContext.contextInfo) { metric.mergeWith({ cwsprChatHasContextList: triggerContext.documentReference?.filePaths?.length ? true : false, - cwsprChatHasWorkspaceContext: triggerContext.hasWorkspace ?? false, cwsprChatFolderContextCount: triggerContext.contextInfo.contextCount.folderContextCount, cwsprChatFileContextCount: triggerContext.contextInfo.contextCount.fileContextCount, cwsprChatRuleContextCount: triggerContext.contextInfo.contextCount.activeRuleContextCount, 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 index c841c994bc..3be50a289e 100644 --- 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 @@ -326,27 +326,6 @@ describe('AdditionalContextProvider', () => { assert.strictEqual(triggerContext.cursorState, undefined) }) - it('should set hasWorkspace flag when @workspace is present', async () => { - const mockWorkspaceFolder = { - uri: URI.file('/workspace').toString(), - name: 'test', - } - sinon.stub(workspaceUtils, 'getWorkspaceFolderPaths').returns(['/workspace']) - const triggerContext: TriggerContext = { - workspaceFolder: mockWorkspaceFolder, - } - - const workspaceContext = [{ id: '@workspace', command: 'Workspace', label: 'folder' }] - ;(chatHistoryDb.getPinnedContext as sinon.SinonStub).returns(workspaceContext) - - fsExistsStub.resolves(false) - getContextCommandPromptStub.resolves([]) - - await provider.getAdditionalContext(triggerContext, 'tab1') - - assert.strictEqual(triggerContext.hasWorkspace, true) - }) - it('should count context types correctly', async () => { const mockWorkspaceFolder = { uri: URI.file('/workspace').toString(), diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.ts index 95370fb402..60a880a956 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.ts @@ -436,9 +436,6 @@ export class AdditionalContextProvider { contextInfo = contextInfo.filter(item => item.id !== ACTIVE_EDITOR_CONTEXT_ID) } - if (contextInfo.some(item => item.id === '@workspace')) { - triggerContext.hasWorkspace = true - } // Handle code symbol ID mismatches between indexing sessions // When a workspace is re-indexed, code symbols receive new IDs // If a pinned symbol's ID is no longer found in the current index: @@ -609,8 +606,19 @@ export class AdditionalContextProvider { const image = imageMap.get(item.description) if (image) ordered.push(image) } else { - const doc = item.route ? docMap.get(path.join(...item.route)) : undefined - if (doc) ordered.push(doc) + const itemPath = item.route ? path.join(...item.route) : undefined + if (itemPath) { + const doc = docMap.get(itemPath) + if (doc) { + ordered.push(doc) + } else if (item.label === 'folder') { + // Folder expands into multiple file entries — match all children + const children = docEntries.filter( + entry => !entry.pinned && entry.path.startsWith(itemPath + path.sep) + ) + ordered.push(...children) + } + } } } // Append pinned context entries (docs and images) 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 8d9c19c13a..3b9efadc94 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 @@ -14,6 +14,7 @@ import { EnvState, Origin, ImageBlock, + RelevantTextDocument, } from '@amzn/codewhisperer-streaming' import { BedrockTools, @@ -22,18 +23,15 @@ import { InlineChatParams, FileList, TextDocument, - OPEN_WORKSPACE_INDEX_SETTINGS_BUTTON_ID, } from '@aws/language-server-runtimes/server-interface' import { Features } from '../../types' import { DocumentContext, DocumentContextExtractor } from '../../chat/contexts/documentContext' import { workspaceUtils } from '@aws/lsp-core' import { URI } from 'vscode-uri' -import { LocalProjectContextController } from '../../../shared/localProjectContextController' import * as path from 'path' -import { RelevantTextDocument } from '@amzn/codewhisperer-streaming' import { languageByExtension } from '../../../shared/languageDetection' import { AgenticChatResultStream } from '../agenticChatResultStream' -import { ContextInfo, mergeFileLists, mergeRelevantTextDocuments } from './contextUtils' +import { ContextInfo } from './contextUtils' import { WorkspaceFolderManager } from '../../workspaceContext/workspaceFolderManager' import { getRelativePathWithWorkspaceFolder } from '../../workspaceContext/util' import { ChatCommandInput } from '../../../shared/streamingClientService' @@ -47,7 +45,6 @@ export interface TriggerContext extends Partial { * Represents the context transparency list displayed at the top of the assistant response. */ documentReference?: FileList - hasWorkspace?: boolean } export type LineInfo = { startLine: number; endLine: number } @@ -178,7 +175,6 @@ export class AgenticChatTriggerContext { const { prompt } = params const workspaceFolders = workspaceUtils.getWorkspaceFolderPaths(this.#workspace).slice(0, maxWorkspaceFolders) const defaultEditorState = { workspaceFolders } - const hasWorkspace = triggerContext.hasWorkspace // prompt.prompt is what user typed in the input, should be sent to backend // prompt.escapedPrompt is HTML serialized string, which should only be used for UI. @@ -190,10 +186,6 @@ export class AgenticChatTriggerContext { promptContent = promptContent.replace(/\*\*@sage\*\*/g, '@sage') } - if (hasWorkspace) { - promptContent = promptContent?.replace(/\*\*@workspace\*\*/, '') - } - // Append remote workspaceId if it exists // Only append workspaceId to GenerateCompletions when WebSocket client is connected const remoteWsFolderManager = WorkspaceFolderManager.getInstance() @@ -204,15 +196,7 @@ export class AgenticChatTriggerContext { undefined this.#logging.info(`remote workspaceId: ${workspaceId}`) - // Get workspace documents if @workspace is used - let relevantDocuments = hasWorkspace - ? await this.#getRelevantDocuments(promptContent ?? '', chatResultStream) - : [] - - const workspaceFileList = mergeRelevantTextDocuments(relevantDocuments) - triggerContext.documentReference = triggerContext.documentReference - ? mergeFileLists(triggerContext.documentReference, workspaceFileList) - : workspaceFileList + const relevantDocuments: RelevantTextDocumentAddition[] = [] // Add @context in prompt to relevantDocuments if (additionalContent) { for (const item of additionalContent.filter(item => !item.pinned)) { @@ -444,81 +428,4 @@ export class AgenticChatTriggerContext { return [...uris] } - - async #getRelevantDocuments( - prompt: string, - chatResultStream?: AgenticChatResultStream - ): Promise { - const localProjectContextController = await LocalProjectContextController.getInstance() - if (!localProjectContextController.isIndexingEnabled() && chatResultStream) { - await chatResultStream.writeResultBlock({ - body: `To add your workspace as context, enable local indexing in your IDE settings. After enabling, add @workspace to your question, and I'll generate a response using your workspace as context.`, - buttons: [ - { - id: OPEN_WORKSPACE_INDEX_SETTINGS_BUTTON_ID, - text: 'Open settings', - icon: 'external', - keepCardAfterClick: false, - status: 'info', - }, - ], - }) - return [] - } - - let relevantTextDocuments = await this.#queryRelevantDocuments(prompt, localProjectContextController) - relevantTextDocuments = relevantTextDocuments.filter(doc => doc.text && doc.text.length > 0) - for (const relevantDocument of relevantTextDocuments) { - if (relevantDocument.text && relevantDocument.text.length > workspaceChunkMaxSize) { - relevantDocument.text = relevantDocument.text.substring(0, workspaceChunkMaxSize) - this.#logging.debug(`Truncating @workspace chunk: ${relevantDocument.relativeFilePath} `) - } - } - - return relevantTextDocuments - } - - async #queryRelevantDocuments( - prompt: string, - localProjectContextController: LocalProjectContextController - ): Promise { - try { - const chunks = await localProjectContextController.queryVectorIndex({ query: prompt }) - const relevantTextDocuments: RelevantTextDocumentAddition[] = [] - if (!chunks) { - return relevantTextDocuments - } - - for (const chunk of chunks) { - const text = chunk.context ?? chunk.content - const baseDocument = { - text, - path: chunk.filePath, - relativeFilePath: chunk.relativePath ?? path.basename(chunk.filePath), - startLine: chunk.startLine ?? -1, - endLine: chunk.endLine ?? -1, - } - - if (chunk.programmingLanguage && chunk.programmingLanguage !== 'unknown') { - relevantTextDocuments.push({ - ...baseDocument, - programmingLanguage: { - languageName: chunk.programmingLanguage, - }, - type: ContentType.WORKSPACE, - }) - } else { - relevantTextDocuments.push({ - ...baseDocument, - type: ContentType.WORKSPACE, - }) - } - } - - return relevantTextDocuments - } catch (e) { - this.#logging.error(`Error querying query vector index to get relevant documents: ${e}`) - return [] - } - } } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.preservation.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.preservation.test.ts index 5fbc63b540..c37a3d7359 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.preservation.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/contextCommandsProvider.preservation.test.ts @@ -61,7 +61,6 @@ describe('Preservation: Context Commands Provider Small Payload Behavior', () => sinon.stub(LocalProjectContextController, 'getInstance').resolves({ onContextItemsUpdated: sinon.stub(), - onIndexingInProgressChanged: sinon.stub(), } as any) provider = new ContextCommandsProvider( 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 811f3d4cf2..9d942ca453 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,4 +1,4 @@ -import { ContextCommandsProvider, CONTEXT_COMMAND_PAYLOAD_CAP, INDEXING_THROTTLE_MS } from './contextCommandsProvider' +import { ContextCommandsProvider, CONTEXT_COMMAND_PAYLOAD_CAP } from './contextCommandsProvider' import * as sinon from 'sinon' import * as fs from 'fs' import { TestFeatures } from '@aws/language-server-runtimes/testing' @@ -26,7 +26,6 @@ describe('ContextCommandsProvider', () => { sinon.stub(LocalProjectContextController, 'getInstance').resolves({ onContextItemsUpdated: sinon.stub(), - onIndexingInProgressChanged: sinon.stub(), } as any) provider = new ContextCommandsProvider( @@ -107,40 +106,6 @@ describe('ContextCommandsProvider', () => { }) }) - describe('onIndexingInProgressChanged', () => { - it('should update workspacePending and call processContextCommandUpdate after throttle window', async () => { - const clock = sinon.useFakeTimers() - let capturedCallback: ((indexingInProgress: boolean) => void) | undefined - - const mockController = { - onContextItemsUpdated: sinon.stub(), - set onIndexingInProgressChanged(callback: (indexingInProgress: boolean) => void) { - capturedCallback = callback - }, - } - - const processUpdateSpy = sinon.spy(provider, 'processContextCommandUpdate') - ;(LocalProjectContextController.getInstance as sinon.SinonStub).resolves(mockController as any) - - // Set initial state to false so condition is met - ;(provider as any).workspacePending = false - - await (provider as any).registerContextCommandHandler() - - capturedCallback?.(true) - - // Not called yet — still within throttle window - sinon.assert.notCalled(processUpdateSpy) - - // Advance past the throttle window - clock.tick(INDEXING_THROTTLE_MS) - - sinon.assert.calledWith(processUpdateSpy, []) - - clock.restore() - }) - }) - describe('setFilesAndFoldersFailed', () => { it('should set filesAndFoldersFailed to true and filesAndFoldersPending to false', () => { provider.setFilesAndFoldersFailed(true) 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 cc7c3d12f6..770262e60b 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 @@ -15,14 +15,6 @@ import { LocalProjectContextController } from '../../../shared/localProjectConte import { URI } from 'vscode-uri' import { activeFileCmd } from './additionalContextProvider' -/** - * Throttle window (in ms) for coalescing rapid `onIndexingInProgressChanged` - * callbacks. When indexing status toggles rapidly (e.g. true→false→true), - * only the final state triggers a `processContextCommandUpdate` call after - * this delay elapses with no further changes. - */ -export const INDEXING_THROTTLE_MS = 500 - /** * Maximum items in the initial `sendContextCommands` push. * The client shows these when the user presses `@` before typing. @@ -69,10 +61,7 @@ export class ContextCommandsProvider implements Disposable { private codeSymbolsFailed = false private filesAndFoldersPending = true private filesAndFoldersFailed = false - private workspacePending = true private initialStateSent = false - /** Handle for the pending indexing-change throttle timer */ - private indexingThrottleTimer?: ReturnType constructor( private readonly logging: Logging, private readonly chat: Chat, @@ -101,28 +90,6 @@ export class ContextCommandsProvider implements Disposable { controller.onContextItemsUpdated = async contextItems => { await this.processContextCommandUpdate(contextItems) } - controller.onIndexingInProgressChanged = (indexingInProgress: boolean) => { - if (this.workspacePending !== indexingInProgress) { - this.workspacePending = indexingInProgress - - // Coalesce rapid indexing status toggles: cancel any pending - // throttle timer and start a new one. Only the final state - // after the throttle window triggers processContextCommandUpdate. - if (this.indexingThrottleTimer !== undefined) { - clearTimeout(this.indexingThrottleTimer) - } - this.indexingThrottleTimer = setTimeout(async () => { - this.indexingThrottleTimer = undefined - try { - const items = await controller.getContextCommandItems() - await this.processContextCommandUpdate(items) - } catch (e) { - this.logging.error(`Error fetching context command items: ${e}`) - void this.processContextCommandUpdate([]) - } - }, INDEXING_THROTTLE_MS) - } - } } catch (e) { this.logging.warn(`Error processing context command update: ${e}`) } @@ -319,13 +286,7 @@ export class ContextCommandsProvider implements Disposable { placeholder: 'Select an image file', } - const workspaceCmd: ContextCommand = { - command: '@workspace', - id: '@workspace', - description: 'Reference all code in workspace', - disabledText: this.workspacePending ? 'pending' : undefined, - } - const commands = [workspaceCmd, folderCmdGroup, fileCmdGroup, codeCmdGroup, promptCmdGroup] + const commands = [folderCmdGroup, fileCmdGroup, codeCmdGroup, promptCmdGroup] if (imageContextEnabled) { commands.push(imageCmdGroup) @@ -402,9 +363,6 @@ export class ContextCommandsProvider implements Disposable { } dispose() { - if (this.indexingThrottleTimer !== undefined) { - clearTimeout(this.indexingThrottleTimer) - } void this.promptFileWatcher?.close() } } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/codeSearch.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/codeSearch.test.ts deleted file mode 100644 index 0488346778..0000000000 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/codeSearch.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import * as assert from 'assert' -import { CodeSearch, CodeSearchOutput } from './codeSearch' -import { testFolder } from '@aws/lsp-core' -import * as path from 'path' -import * as fs from 'fs/promises' -import { TestFeatures } from '@aws/language-server-runtimes/testing' -import { Features } from '@aws/language-server-runtimes/server-interface/server' -import { LocalProjectContextController } from '../../../shared/localProjectContextController' -import { Chunk } from 'local-indexing' -import { stub, restore, SinonStub } from 'sinon' - -describe('CodeSearch Tool', () => { - let tempFolder: testFolder.TestFolder - let testFeatures: TestFeatures - let mockLocalProjectContextController: Partial - let getInstanceStub: SinonStub - - before(async () => { - testFeatures = new TestFeatures() - testFeatures.workspace.fs.exists = path => - fs.access(path).then( - () => true, - () => false - ) - tempFolder = await testFolder.TestFolder.create() - - mockLocalProjectContextController = { - isEnabled: true, - queryVectorIndex: stub().resolves([]), - } - - // Stub the getInstance method - getInstanceStub = stub(LocalProjectContextController, 'getInstance').resolves( - mockLocalProjectContextController as LocalProjectContextController - ) - }) - - after(async () => { - await tempFolder.delete() - restore() // Restore all stubbed methods - }) - - it('invalidates empty query', async () => { - const codeSearch = new CodeSearch(testFeatures) - await assert.rejects( - codeSearch.validate({ query: '' }), - /Code search query cannot be empty/i, - 'Expected an error about empty query' - ) - }) - - it('returns empty results when no matches found', async () => { - const codeSearch = new CodeSearch(testFeatures) - const result = await codeSearch.invoke({ query: 'nonexistent code' }) - - assert.strictEqual(result.output.kind, 'text') - assert.strictEqual(result.output.content, 'No code matches found for code search.') - }) - - it('returns formatted results when matches found', async () => { - // Create mock chunks that would be returned from vector search - const mockChunks: Chunk[] = [ - { - content: 'function testFunction() { return true; }', - filePath: path.join(tempFolder.path, 'test.js'), - relativePath: 'test.js', - startLine: 1, - endLine: 3, - programmingLanguage: 'javascript', - id: '', - index: 0, - vec: [], - }, - ] - - // Configure the mock to return our test chunks - ;(mockLocalProjectContextController.queryVectorIndex as SinonStub).resolves(mockChunks) - - const codeSearch = new CodeSearch(testFeatures) - const result = await codeSearch.invoke({ query: 'testFunction' }) - - assert.strictEqual(result.output.kind, 'json') - const content = result.output.content as CodeSearchOutput[] - assert.strictEqual(Array.isArray(content), true) - assert.strictEqual(content.length, 1) - assert.strictEqual(content[0].text, 'function testFunction() { return true; }') - assert.strictEqual(content[0].relativeFilePath, 'test.js') - assert.strictEqual(content[0].startLine, 1) - assert.strictEqual(content[0].endLine, 3) - assert.strictEqual(content[0].programmingLanguage?.languageName, 'javascript') - }) - - it('handles chunks without programming language', async () => { - // Create mock chunks without programming language - const mockChunks: Chunk[] = [ - { - content: 'Some plain text content', - filePath: path.join(tempFolder.path, 'readme.txt'), - relativePath: 'readme.txt', - startLine: 1, - endLine: 1, - id: '', - index: 0, - vec: [], - }, - ] - - // Configure the mock to return our test chunks - ;(mockLocalProjectContextController.queryVectorIndex as SinonStub).resolves(mockChunks) - - const codeSearch = new CodeSearch(testFeatures) - const result = await codeSearch.invoke({ query: 'plain text' }) - - assert.strictEqual(result.output.kind, 'json') - const content = result.output.content as CodeSearchOutput[] - assert.strictEqual(content.length, 1) - assert.strictEqual(content[0].text, 'Some plain text content') - assert.strictEqual(content[0].relativeFilePath, 'readme.txt') - assert.strictEqual(content[0].programmingLanguage, undefined) - }) - - it('uses default workspace folder when path not provided', async () => { - const codeSearch = new CodeSearch(testFeatures) - await codeSearch.invoke({ query: 'test query' }) - - // Verify that queryVectorIndex was called - assert.strictEqual((mockLocalProjectContextController.queryVectorIndex as SinonStub).called, true) - }) - - it('handles errors from LocalProjectContextController', async () => { - // Configure the mock to throw an error - ;(mockLocalProjectContextController.queryVectorIndex as SinonStub).rejects(new Error('Test error')) - - const codeSearch = new CodeSearch(testFeatures) - await assert.rejects( - codeSearch.invoke({ query: 'error test' }), - /Failed to perform code search/, - 'Expected an error when vector search fails' - ) - }) - - it('provides correct queue description', async () => { - const codeSearch = new CodeSearch(testFeatures) - - // Create a mock WritableStream - let capturedDescription = '' - const mockWriter = { - write: async (content: string) => { - capturedDescription = content - return Promise.resolve() - }, - close: async () => Promise.resolve(), - releaseLock: () => {}, - } - const mockStream = { - getWriter: () => mockWriter, - } as unknown as WritableStream - - await codeSearch.queueDescription({ query: 'test query' }, mockStream, true) - assert.strictEqual(capturedDescription, 'Performing code search for "test query" in ') - }) - - it('returns correct tool specification', () => { - const codeSearch = new CodeSearch(testFeatures) - const spec = codeSearch.getSpec() - - assert.strictEqual(spec.name, 'codeSearch') - assert.ok(spec.description.includes('Find snippets of code')) - assert.deepStrictEqual(spec.inputSchema.required, ['query']) - }) -}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/codeSearch.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/codeSearch.ts deleted file mode 100644 index 167175e71b..0000000000 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/codeSearch.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { CommandValidation, InvokeOutput, requiresPathAcceptance, validatePath } from './toolShared' -import { Features } from '@aws/language-server-runtimes/server-interface/server' -import { getWorkspaceFolderPaths } from '@aws/lsp-core/out/util/workspaceUtils' -import { LocalProjectContextController } from '../../../shared/localProjectContextController' -import { Chunk } from 'local-indexing' -import { RelevantTextDocument } from '@amzn/codewhisperer-streaming' -import { LineInfo } from '../context/agenticChatTriggerContext' -import path = require('path') - -export interface CodeSearchParams { - query: string -} - -export type CodeSearchOutput = RelevantTextDocument & LineInfo - -export class CodeSearch { - private readonly logging: Features['logging'] - private readonly workspace: Features['workspace'] - private readonly lsp: Features['lsp'] - constructor(features: Pick) { - this.logging = features.logging - this.workspace = features.workspace - this.lsp = features.lsp - } - - public async validate(params: CodeSearchParams): Promise { - if (!params.query || params.query.trim().length === 0) { - throw new Error('Code search query cannot be empty.') - } - const searchPath = this.getOrSetSearchPath() - - if (searchPath) { - await validatePath(searchPath, this.workspace.fs.exists) - } - } - - public async queueDescription(params: CodeSearchParams, updates: WritableStream, requiresAcceptance: boolean) { - const writer = updates.getWriter() - const closeWriter = async (w: WritableStreamDefaultWriter) => { - await w.close() - w.releaseLock() - } - if (!requiresAcceptance) { - await writer.write('') - await closeWriter(writer) - return - } - - const path = this.getOrSetSearchPath() - await writer.write(`Performing code search for "${params.query}" in ${path}`) - await closeWriter(writer) - } - - public async invoke(params: CodeSearchParams): Promise { - const path = this.getOrSetSearchPath() - - try { - const results = await this.executeCodeSearch(params.query) - return this.createOutput( - !results || results.length === 0 ? 'No code matches found for code search.' : results - ) - } catch (error: any) { - this.logging.error( - `Failed to perform code search for "${params.query}" in workspace "${path}": ${error.message || error}` - ) - throw new Error( - `Failed to perform code search for "${params.query}" in workspace"${path}": ${error.message || error}` - ) - } - } - - private getOrSetSearchPath(path?: string): string { - let searchPath = '' - if (path && path.trim().length !== 0) { - searchPath = path - } else { - // Handle optional path parameter - // Use current workspace folder as default if path is not provided - const workspaceFolders = getWorkspaceFolderPaths(this.workspace) - if (workspaceFolders && workspaceFolders.length !== 0) { - this.logging.debug(`Using default workspace folder: ${workspaceFolders[0]}`) - searchPath = workspaceFolders[0] - } - } - return searchPath - } - - private async executeCodeSearch(query: string): Promise { - this.logging.info(`Executing code search for "${query}" in "${path}"`) - const localProjectContextController = await LocalProjectContextController.getInstance() - - if (!localProjectContextController.isEnabled) { - this.logging.warn(`Error during code search: local project context controller is disabled`) - throw new Error(`Error during code search: Amazon Q Workspace Index disabled, - please update the configuration to enable Amazon Q workspace Index`) - } - try { - // TODO: we need to handle the validation of workspace indexing status once localProjectContextController support - // check the indexing status. - // Use the localProjectContextController to query the vector index - const searchResults = await localProjectContextController.queryVectorIndex({ query: query }) - const sanitizedSearchResults = this.parseChunksToCodeSearchOutput(searchResults) - this.logging.info(`Code searched succeed with num of results: "${sanitizedSearchResults.length}"`) - return sanitizedSearchResults - } catch (error: any) { - this.logging.error(`Error during code search: ${error.message || error}`) - throw error - } - } - - /** - * Parses chunks from vector index search into CodeSearchOutput format - * Based on the queryRelevantDocuments method pattern - */ - private parseChunksToCodeSearchOutput(chunks: Chunk[]): CodeSearchOutput[] { - const codeSearchResults: CodeSearchOutput[] = [] - if (!chunks) { - return codeSearchResults - } - - for (const chunk of chunks) { - // Extract content and context - const text = chunk.content || '' - const relativeFilePath = chunk.relativePath ?? path.basename(chunk.filePath) - - // Extract line information - const startLine = chunk.startLine ?? -1 - const endLine = chunk.endLine ?? -1 - - // Create the base search result - const baseSearchResult = { - text, - relativeFilePath, - startLine, - endLine, - } - - // Add programming language information if available - if (chunk.programmingLanguage && chunk.programmingLanguage !== 'unknown') { - codeSearchResults.push({ - ...baseSearchResult, - programmingLanguage: { - languageName: chunk.programmingLanguage, - }, - }) - } else { - codeSearchResults.push(baseSearchResult) - } - } - - return codeSearchResults - } - - private createOutput(content: string | any[]): InvokeOutput { - if (typeof content === 'string') { - return { - output: { - kind: 'text', - content: content, - }, - } - } else { - return { - output: { - kind: 'json', - content: content, - }, - } - } - } - - public getSpec() { - return { - name: 'codeSearch', - description: - "Find snippets of code from the codebase most relevant to the search query.\nThis is a semantic search tool, so the query should ask for something semantically matching what is needed.\nUnless there is a clear reason to use your own search query, please just reuse the user's exact query with their wording.\nTheir exact wording/phrasing can often be helpful for the semantic search query. Keeping the same exact question format can also be helpful.", - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: 'The search query to find relevant code.', - }, - explanation: { - type: 'string', - description: - 'One sentence explanation as to why this tool is being used, and how it contributes to the goal', - }, - }, - required: ['query'], - }, - } as const - } -} diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/triggerContext.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/triggerContext.ts index 3d843a9ce3..98ca46983b 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/triggerContext.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/triggerContext.ts @@ -4,8 +4,7 @@ import { BedrockTools, ChatParams, CursorState, InlineChatParams } from '@aws/la import { Features } from '../../types' import { DocumentContext, DocumentContextExtractor } from './documentContext' import { SendMessageCommandInput } from '../../../shared/streamingClientService' -import { LocalProjectContextController } from '../../../shared/localProjectContextController' -import { convertChunksToRelevantTextDocuments } from '../tools/relevantTextDocuments' + import { AmazonQBaseServiceManager as AmazonQServiceManager } from '../../../shared/amazonQServiceManager/BaseAmazonQServiceManager' export interface TriggerContext extends Partial { @@ -35,17 +34,11 @@ export class QChatTriggerContext { async getNewTriggerContext(params: ChatParams | InlineChatParams): Promise { const documentContext: DocumentContext | undefined = await this.extractDocumentContext(params) - const useRelevantDocuments = - 'context' in params - ? params.context?.some(context => typeof context !== 'string' && context.command === '@workspace') - : false - let relevantDocuments = useRelevantDocuments ? await this.extractProjectContext(params.prompt.prompt) : [] - return { ...documentContext, userIntent: this.#guessIntentFromPrompt(params.prompt.prompt), - useRelevantDocuments, - relevantDocuments, + useRelevantDocuments: false, + relevantDocuments: [], } } @@ -134,32 +127,6 @@ export class QChatTriggerContext { : undefined } - async extractProjectContext(query?: string): Promise { - if (query) { - try { - let enableWorkspaceContext = true - - if (this.amazonQServiceManager) { - const config = this.amazonQServiceManager.getConfiguration() - if (config.projectContext?.enableLocalIndexing === false) { - enableWorkspaceContext = false - } - } - - if (!enableWorkspaceContext) { - this.#logger.debug('Workspace context is disabled, skipping project context extraction') - return [] - } - const contextController = await LocalProjectContextController.getInstance() - const resp = await contextController.queryVectorIndex({ query }) - return convertChunksToRelevantTextDocuments(resp) - } catch (e) { - this.#logger.error(`Failed to extract project context for chat trigger: ${e}`) - } - } - return [] - } - #guessIntentFromPrompt(prompt?: string): UserIntent | undefined { if (prompt === undefined) { return undefined diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/triggerContexts.test.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/triggerContexts.test.ts index 79b33625e8..3cc6894682 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/triggerContexts.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/contexts/triggerContexts.test.ts @@ -116,26 +116,4 @@ describe('QChatTriggerContext', () => { assert.deepStrictEqual(documentContext, mockDocumentContext) }) - - it('should not extract project context when workspace context is disabled', async () => { - amazonQServiceManager.getConfiguration.returns({ - projectContext: { - enableLocalIndexing: false, - }, - }) - - const triggerContext = new QChatTriggerContext( - testFeatures.workspace, - testFeatures.logging, - amazonQServiceManager - ) - - const getInstanceStub = sinon.stub(LocalProjectContextController, 'getInstance') - - const result = await triggerContext.extractProjectContext('test query') - - sinon.assert.notCalled(getInstanceStub) - - assert.deepStrictEqual(result, []) - }) }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts index b86c51e448..5209e0f6e1 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts @@ -338,7 +338,6 @@ export class ChatTelemetryController { chatConversationType: metric.cwsprChatConversationType, chatActiveEditorImportCount: metric.cwsprChatActiveEditorImportCount, cwsprChatHasContextList: metric.cwsprChatHasContextList, - cwsprChatHasWorkspaceContext: metric.cwsprChatHasWorkspaceContext, cwsprChatFolderContextCount: metric.cwsprChatFolderContextCount, cwsprChatFileContextCount: metric.cwsprChatFileContextCount, cwsprChatRuleContextCount: metric.cwsprChatRuleContextCount, 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 ac96094354..05b69513c5 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/localProjectContext/localProjectContextServer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/localProjectContext/localProjectContextServer.ts @@ -22,7 +22,6 @@ export const LocalProjectContextServer = let amazonQServiceManager: AmazonQBaseServiceManager let telemetryService: TelemetryService - let localProjectContextEnabled: boolean = false let VSCWindowsOverride: boolean = false lsp.addInitializer((params: InitializeParams) => { @@ -164,22 +163,13 @@ export const LocalProjectContextServer = const updateConfigurationHandler = async (updatedConfig: AmazonQWorkspaceConfig) => { logging.log('Updating configuration of local context server') try { - localProjectContextEnabled = updatedConfig.projectContext?.enableLocalIndexing === true if (process.env.DISABLE_INDEXING_LIBRARY === 'true') { logging.log('Skipping local project context initialization') - localProjectContextEnabled = false } else { - logging.log( - `Setting project context indexing enabled to ${updatedConfig.projectContext?.enableLocalIndexing}` - ) await localProjectContextController.init({ - enableGpuAcceleration: updatedConfig?.projectContext?.enableGpuAcceleration, - indexWorkerThreads: updatedConfig?.projectContext?.indexWorkerThreads, ignoreFilePatterns: updatedConfig.projectContext?.localIndexing?.ignoreFilePatterns, maxFileSizeMB: updatedConfig.projectContext?.localIndexing?.maxFileSizeMB, maxIndexSizeMB: updatedConfig.projectContext?.localIndexing?.maxIndexSizeMB, - enableIndexing: localProjectContextEnabled, - indexCacheDirPath: updatedConfig.projectContext?.localIndexing?.indexCacheDirPath, }) } } catch (error) { diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/configurationUtils.test.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/configurationUtils.test.ts index 52dc9b714f..d2eb6455fd 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/configurationUtils.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/configurationUtils.test.ts @@ -21,14 +21,10 @@ describe('getAmazonQRelatedWorkspaceConfigs', () => { extraContext: 'some-inline-chat-context', }, projectContext: { - enableLocalIndexing: true, - enableGpuAcceleration: true, - indexWorkerThreads: 1, localIndexing: { ignoreFilePatterns: [], maxFileSizeMB: 10, maxIndexSizeMB: 2048, - indexCacheDirPath: undefined, }, }, } @@ -60,9 +56,6 @@ describe('getAmazonQRelatedWorkspaceConfigs', () => { shareCodeWhispererContentWithAWS: MOCKED_AWS_CODEWHISPERER_SECTION.shareCodeWhispererContentWithAWS, sendUserWrittenCodeMetrics: MOCKED_AWS_CODEWHISPERER_SECTION.sendUserWrittenCodeMetrics, projectContext: { - enableLocalIndexing: MOCKED_AWS_Q_SECTION.projectContext.enableLocalIndexing, - enableGpuAcceleration: MOCKED_AWS_Q_SECTION.projectContext?.enableGpuAcceleration, - indexWorkerThreads: MOCKED_AWS_Q_SECTION.projectContext?.indexWorkerThreads, localIndexing: MOCKED_AWS_Q_SECTION.projectContext.localIndexing, }, } @@ -112,14 +105,10 @@ describe('AmazonQConfigurationCache', () => { shareCodeWhispererContentWithAWS: true, sendUserWrittenCodeMetrics: false, projectContext: { - enableLocalIndexing: true, - enableGpuAcceleration: true, - indexWorkerThreads: 1, localIndexing: { ignoreFilePatterns: [], maxFileSizeMB: 10, maxIndexSizeMB: 2048, - indexCacheDirPath: undefined, }, }, } @@ -136,9 +125,9 @@ describe('AmazonQConfigurationCache', () => { mockedQConfig.customizationArn = undefined mockedQConfig.inlineSuggestions = { extraContext: undefined } mockedQConfig.projectContext = { - enableLocalIndexing: false, - enableGpuAcceleration: false, - indexWorkerThreads: 0, + localIndexing: { + ignoreFilePatterns: ['*.log'], + }, } notDeepStrictEqual(cache.getProperty('customizationArn'), mockedQConfig.customizationArn) notDeepStrictEqual(cache.getProperty('inlineSuggestions'), mockedQConfig.inlineSuggestions) diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/configurationUtils.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/configurationUtils.ts index 8adb68f2d0..899a2909ef 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/configurationUtils.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/configurationUtils.ts @@ -75,13 +75,9 @@ interface LocalIndexConfig { ignoreFilePatterns?: string[] // patterns must follow .gitignore convention maxFileSizeMB?: number maxIndexSizeMB?: number - indexCacheDirPath?: string // defaults to homedir/.aws/amazonq/cache } interface QProjectContextConfig { - enableLocalIndexing: boolean // aws.q.projectContext.enableLocalIndexing - enableGpuAcceleration: boolean // aws.q.projectContext.enableGpuAcceleration - indexWorkerThreads: number // aws.q.projectContext.indexWorkerThreads localIndexing?: LocalIndexConfig } @@ -139,14 +135,10 @@ export async function getAmazonQRelatedWorkspaceConfigs( extraContext: textUtils.undefinedIfEmpty(newQConfig.inlineChat?.extraContext), }, projectContext: { - enableLocalIndexing: newQConfig.projectContext?.enableLocalIndexing === true, - enableGpuAcceleration: newQConfig.projectContext?.enableGpuAcceleration === true, - indexWorkerThreads: newQConfig.projectContext?.indexWorkerThreads ?? -1, localIndexing: { ignoreFilePatterns: newQConfig.projectContext?.localIndexing?.ignoreFilePatterns ?? [], maxFileSizeMB: newQConfig.projectContext?.localIndexing?.maxFileSizeMB ?? 10, maxIndexSizeMB: newQConfig.projectContext?.localIndexing?.maxIndexSizeMB ?? 2048, - indexCacheDirPath: newQConfig.projectContext?.localIndexing?.indexCacheDirPath ?? undefined, }, }, } @@ -202,14 +194,10 @@ export const defaultAmazonQWorkspaceConfigFactory = (): AmazonQWorkspaceConfig = shareCodeWhispererContentWithAWS: false, sendUserWrittenCodeMetrics: false, projectContext: { - enableLocalIndexing: false, - enableGpuAcceleration: false, - indexWorkerThreads: -1, localIndexing: { ignoreFilePatterns: [], maxFileSizeMB: 10, maxIndexSizeMB: 2048, - indexCacheDirPath: undefined, }, }, } diff --git a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.test.ts b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.test.ts index ef73e5c20a..a11e499b30 100644 --- a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.test.ts @@ -46,8 +46,6 @@ describe('LocalProjectContextController', () => { vectorLibMock = { start: stub().resolves({ buildIndex: stub().resolves(), - clear: stub().resolves(), - queryVectorIndex: stub().resolves(['mockChunk1', 'mockChunk2']), queryInlineProjectContext: stub().resolves(['mockContext1']), updateIndexV2: stub().resolves(), getContextCommandItems: stub().resolves([]), @@ -80,7 +78,7 @@ describe('LocalProjectContextController', () => { describe('init', () => { it('should initialize vector library successfully', async () => { const buildIndexSpy = spy(controller, 'buildIndex') - await controller.init({ vectorLib: vectorLibMock, enableIndexing: true }) + await controller.init({ vectorLib: vectorLibMock }) sinonAssert.notCalled(logging.error) sinonAssert.called(vectorLibMock.start) @@ -96,24 +94,13 @@ describe('LocalProjectContextController', () => { sinonAssert.called(logging.error) }) - it('should call buildIndex with `default` if not enabled', async () => { - const buildIndexSpy = spy(controller, 'buildIndex') - await controller.init({ vectorLib: vectorLibMock, enableIndexing: false }) - - sinonAssert.notCalled(logging.error) - sinonAssert.called(vectorLibMock.start) - sinonAssert.calledOnce(buildIndexSpy) - sinonAssert.calledWith(buildIndexSpy, 'default') - }) - - it('should call buildIndex with `all` when enabled', async () => { + it('should call buildIndex on init', async () => { const buildIndexSpy = spy(controller, 'buildIndex') - await controller.init({ vectorLib: vectorLibMock, enableIndexing: true }) + await controller.init({ vectorLib: vectorLibMock }) sinonAssert.notCalled(logging.error) sinonAssert.called(vectorLibMock.start) sinonAssert.calledOnce(buildIndexSpy) - sinonAssert.calledWith(buildIndexSpy, 'all') }) }) @@ -121,64 +108,16 @@ describe('LocalProjectContextController', () => { it('should build Index with vectorLib', async () => { await controller.init({ vectorLib: vectorLibMock }) const vecLib = await vectorLibMock.start() - await controller.buildIndex('all') + await controller.buildIndex() sinonAssert.called(vecLib.buildIndex) }) }) - - describe('queryVectorIndex', () => { - beforeEach(async () => { - await controller.init({ vectorLib: vectorLibMock }) - }) - - it('should return empty array when vector library is not initialized', async () => { - sinon.stub(controller, 'isIndexingEnabled').returns(true) - const uninitializedController = new LocalProjectContextController( - 'testClient', - mockWorkspaceFolders, - logging as any - ) - - const result = await uninitializedController.queryVectorIndex({ query: 'test' }) - assert.deepStrictEqual(result, []) - }) - - it('should return empty array when indexing is disabled', async () => { - sinon.stub(controller, 'isIndexingEnabled').returns(false) - const uninitializedController = new LocalProjectContextController( - 'testClient', - mockWorkspaceFolders, - logging as any - ) - - const result = await uninitializedController.queryVectorIndex({ query: 'test' }) - assert.deepStrictEqual(result, []) - }) - - it('should return chunks from vector library', async () => { - sinon.stub(controller, 'isIndexingEnabled').returns(true) - const result = await controller.queryVectorIndex({ query: 'test' }) - assert.deepStrictEqual(result, ['mockChunk1', 'mockChunk2']) - }) - - it('should handle query errors', async () => { - sinon.stub(controller, 'isIndexingEnabled').returns(true) - const vecLib = await vectorLibMock.start() - vecLib.queryVectorIndex.rejects(new Error('Query failed')) - - const result = await controller.queryVectorIndex({ query: 'test' }) - assert.deepStrictEqual(result, []) - sinonAssert.called(logging.error) - }) - }) - describe('queryInlineProjectContext', () => { beforeEach(async () => { await controller.init({ vectorLib: vectorLibMock }) }) it('should return empty array when vector library is not initialized', async () => { - sinon.stub(controller, 'isIndexingEnabled').returns(true) const uninitializedController = new LocalProjectContextController( 'testClient', mockWorkspaceFolders, @@ -194,7 +133,6 @@ describe('LocalProjectContextController', () => { }) it('should return empty array when indexing is disabled', async () => { - sinon.stub(controller, 'isIndexingEnabled').returns(false) const uninitializedController = new LocalProjectContextController( 'testClient', mockWorkspaceFolders, @@ -210,7 +148,6 @@ describe('LocalProjectContextController', () => { }) it('should return context from vector library', async () => { - sinon.stub(controller, 'isIndexingEnabled').returns(true) const result = await controller.queryInlineProjectContext({ query: 'test', filePath: 'test.java', @@ -220,7 +157,6 @@ describe('LocalProjectContextController', () => { }) it('should handle query errors', async () => { - sinon.stub(controller, 'isIndexingEnabled').returns(true) const vecLib = await vectorLibMock.start() vecLib.queryInlineProjectContext.rejects(new Error('Query failed')) @@ -353,85 +289,12 @@ describe('LocalProjectContextController', () => { }) }) - describe('configuration options', () => { - let processEnvBackup: NodeJS.ProcessEnv - - beforeEach(() => { - processEnvBackup = { ...process.env } - }) - - afterEach(() => { - process.env = processEnvBackup - }) - - it('should set GPU acceleration environment variable when enabled', async () => { - await controller.init({ - enableGpuAcceleration: true, - vectorLib: vectorLibMock, - }) - assert.strictEqual(process.env.Q_ENABLE_GPU, 'true') - sinonAssert.called(vectorLibMock.start) - }) - - it('should remove GPU acceleration environment variable when disabled', async () => { - process.env.Q_ENABLE_GPU = 'true' - await controller.init({ - enableGpuAcceleration: false, - vectorLib: vectorLibMock, - }) - assert.strictEqual(process.env.Q_ENABLE_GPU, undefined) - sinonAssert.called(vectorLibMock.start) - }) - - it('should set worker threads environment variable when specified', async () => { - await controller.init({ - indexWorkerThreads: 4, - vectorLib: vectorLibMock, - }) - assert.strictEqual(process.env.Q_WORKER_THREADS, '4') - sinonAssert.called(vectorLibMock.start) - }) - - it('should remove worker threads environment variable when not specified', async () => { - process.env.Q_WORKER_THREADS = '4' - await controller.init({ - vectorLib: vectorLibMock, - }) - assert.strictEqual(process.env.Q_WORKER_THREADS, undefined) - sinonAssert.called(vectorLibMock.start) - }) - - it('should ignore invalid worker thread counts', async () => { - process.env.Q_WORKER_THREADS = '4' - await controller.init({ - indexWorkerThreads: 101, - vectorLib: vectorLibMock, - }) - assert.strictEqual(process.env.Q_WORKER_THREADS, undefined) - sinonAssert.called(vectorLibMock.start) - }) - - it('should ignore negative worker thread counts', async () => { - process.env.Q_WORKER_THREADS = '4' - await controller.init({ - indexWorkerThreads: -1, - vectorLib: vectorLibMock, - }) - assert.strictEqual(process.env.Q_WORKER_THREADS, undefined) - sinonAssert.called(vectorLibMock.start) - }) - }) - describe('dispose', () => { it('should clear and remove vector library reference', async () => { await controller.init({ vectorLib: vectorLibMock }) await controller.dispose() - const vecLib = await vectorLibMock.start() - sinonAssert.called(vecLib.clear) - - const queryResult = await controller.queryVectorIndex({ query: 'test' }) - assert.deepStrictEqual(queryResult, []) + assert.strictEqual(controller.isEnabled, false) }) }) }) diff --git a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts index 527f92f3b4..0cc0495bc1 100644 --- a/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts +++ b/server/aws-lsp-codewhisperer/src/shared/localProjectContextController.ts @@ -8,7 +8,6 @@ import type { ContextCommandItem, InlineProjectContext, QueryInlineProjectContextRequestV2, - QueryRequest, UpdateMode, VectorLibAPI, } from 'local-indexing' @@ -41,17 +40,11 @@ export interface LocalProjectContextInitializationOptions { includeSymlinks?: boolean maxFileSizeMB?: number maxIndexSizeMB?: number - indexCacheDirPath?: string - enableGpuAcceleration?: boolean - indexWorkerThreads?: number - enableIndexing?: boolean } export class LocalProjectContextController { // Event handler for context items updated public onContextItemsUpdated: ((contextItems: ContextCommandItem[]) => Promise) | undefined - // Event handler for when index is being built - public onIndexingInProgressChanged: ((enabled: boolean) => void) | undefined private static instance: LocalProjectContextController | undefined private workspaceFolders: WorkspaceFolder[] @@ -59,14 +52,12 @@ export class LocalProjectContextController { private _contextCommandSymbolsUpdated = false private readonly clientName: string private readonly log: Logging - private _isIndexingEnabled: boolean = false private _isIndexingInProgress: boolean = false private ignoreFilePatterns?: string[] private includeSymlinks?: boolean private maxFileSizeMB?: number private maxIndexSizeMB?: number private respectUserGitIgnores?: boolean - private indexCacheDirPath: string = path.join(homedir(), '.aws', 'amazonq', 'cache') private readonly fileExtensions: string[] = Object.keys(languageByExtension) private readonly DEFAULT_MAX_INDEX_SIZE_MB = 2048 @@ -108,10 +99,6 @@ export class LocalProjectContextController { includeSymlinks = false, maxFileSizeMB = this.DEFAULT_MAX_FILE_SIZE_MB, maxIndexSizeMB = this.DEFAULT_MAX_INDEX_SIZE_MB, - indexCacheDirPath = path.join(homedir(), '.aws', 'amazonq', 'cache'), - enableGpuAcceleration = false, - indexWorkerThreads = 0, - enableIndexing = false, }: LocalProjectContextInitializationOptions = {}): Promise { try { // update states according to configuration @@ -120,66 +107,30 @@ export class LocalProjectContextController { this.maxIndexSizeMB = maxIndexSizeMB this.respectUserGitIgnores = respectUserGitIgnores this.ignoreFilePatterns = ignoreFilePatterns - if (indexCacheDirPath?.length > 0 && path.parse(indexCacheDirPath)) { - this.indexCacheDirPath = indexCacheDirPath - } - if (enableGpuAcceleration) { - process.env.Q_ENABLE_GPU = 'true' - } else { - delete process.env.Q_ENABLE_GPU - } - if (indexWorkerThreads && indexWorkerThreads > 0 && indexWorkerThreads < 100) { - process.env.Q_WORKER_THREADS = indexWorkerThreads.toString() - } else { - delete process.env.Q_WORKER_THREADS - } - this.log.info( - `Vector library initializing with GPU acceleration: ${enableGpuAcceleration}, ` + - `index worker thread count: ${indexWorkerThreads}` - ) + this.log.info('Initializing local project context') - // build index if vecLib was initialized but indexing was not enabled before + // skip re-init if vecLib already loaded if (this._vecLib) { - // if indexing is turned being on, build index with 'all' that supports vector indexing - if (enableIndexing && !this._isIndexingEnabled) { - this.buildIndex('all').catch(e => { - this.log.error(`Error building index with indexing enabled: ${e}`) - }) - } - // if indexing is turned being off, build index with 'default' that does not support vector indexing - if (!enableIndexing && this._isIndexingEnabled) { - this.buildIndex('default').catch(e => { - this.log.error(`Error building index with indexing disabled: ${e}`) - }) - } - this._isIndexingEnabled = enableIndexing return } - // initialize vecLib and index if needed + // initialize vecLib and index const libraryPath = this.getVectorLibraryPath() const vecLib = vectorLib ?? (await eval(`import("${libraryPath}")`)) if (vecLib) { try { - this._vecLib = await vecLib.start(LIBRARY_DIR, this.clientName, this.indexCacheDirPath) + this._vecLib = await vecLib.start(LIBRARY_DIR, this.clientName) } catch (startError) { this.log.warn(`Vector library start() failed (native modules may be missing): ${startError}`) this.log.warn('Context commands will be unavailable') } if (this._vecLib) { - if (enableIndexing) { - this.buildIndex('all').catch(e => { - this.log.error(`Error building index on init with indexing enabled: ${e}`) - }) - } else { - this.buildIndex('default').catch(e => { - this.log.error(`Error building index on init with indexing disabled: ${e}`) - }) - } + this.buildIndex().catch(e => { + this.log.error(`Error building index on init: ${e}`) + }) } LocalProjectContextController.instance = this - this._isIndexingEnabled = enableIndexing } else { this.log.warn(`Vector library could not be imported from: ${libraryPath}`) LocalProjectContextController.instance = this @@ -219,13 +170,12 @@ export class LocalProjectContextController { } // public for test - async buildIndex(indexingType: string): Promise { + async buildIndex(): Promise { if (this._isIndexingInProgress) { return } try { this._isIndexingInProgress = true - this.onIndexingInProgressChanged?.(this._isIndexingInProgress) if (this._vecLib) { if (!this.workspaceFolders.length) { this.log.info('skip building index because no workspace folder found') @@ -239,14 +189,13 @@ export class LocalProjectContextController { ) const projectRoot = URI.parse(this.workspaceFolders.sort()[0].uri).fsPath - await this._vecLib?.buildIndex(sourceFiles, projectRoot, indexingType) + await this._vecLib?.buildIndex(sourceFiles, projectRoot, 'default') this.log.info('Context index built successfully') } } catch (error) { this.log.error(`Error building index: ${error}`) } finally { this._isIndexingInProgress = false - this.onIndexingInProgressChanged?.(this._isIndexingInProgress) } } @@ -285,7 +234,6 @@ export class LocalProjectContextController { public async queryInlineProjectContext( request: QueryInlineProjectContextRequestV2 ): Promise { - // inline project context is available for all users regardless of local indexing enabled or disabled try { const resp = await this._vecLib?.queryInlineProjectContext(request.query, request.filePath, request.target) return resp ?? [] @@ -295,20 +243,6 @@ export class LocalProjectContextController { } } - public async queryVectorIndex(request: QueryRequest): Promise { - if (!this.isIndexingEnabled()) { - return [] - } - - try { - const resp = await this._vecLib?.queryVectorIndex(request.query) - return resp ?? [] - } catch (error) { - this.log.error(`Error in queryVectorIndex: ${error}`) - return [] - } - } - public async getContextCommandItems(): Promise { if (!this._vecLib) { return [] @@ -397,10 +331,6 @@ export class LocalProjectContextController { } } - public isIndexingEnabled(): boolean { - return this._vecLib !== undefined && this._isIndexingEnabled - } - async processWorkspaceFolders( workspaceFolders?: WorkspaceFolder[] | null, fileExtensions?: string[], diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts index 66f81af680..32536f73fd 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts @@ -775,7 +775,6 @@ describe('TelemetryService', () => { }, { cwsprChatHasContextList: true, - cwsprChatHasWorkspaceContext: false, cwsprChatFolderContextCount: 0, cwsprChatFileContextCount: 0, cwsprChatRuleContextCount: 0, @@ -845,7 +844,6 @@ describe('TelemetryService', () => { experimentName: undefined, userVariation: undefined, cwsprChatHasContextList: true, - cwsprChatHasWorkspaceContext: false, cwsprChatFolderContextCount: 0, cwsprChatFileContextCount: 0, cwsprChatRuleContextCount: 0, diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts index f7c93b6f14..861333d302 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts @@ -563,7 +563,6 @@ export class TelemetryService { chatConversationType: ChatConversationType chatActiveEditorImportCount?: number cwsprChatHasContextList: boolean - cwsprChatHasWorkspaceContext?: boolean cwsprChatFolderContextCount: number cwsprChatFileContextCount: number cwsprChatFileContextLength: number @@ -617,7 +616,6 @@ export class TelemetryService { cwsprChatActiveEditorImportCount: additionalParams.chatActiveEditorImportCount, codewhispererCustomizationArn: params.customizationArn, cwsprChatHasContextList: additionalParams.cwsprChatHasContextList, - cwsprChatHasWorkspaceContext: additionalParams.cwsprChatHasWorkspaceContext, cwsprChatFolderContextCount: additionalParams.cwsprChatFolderContextCount, cwsprChatFileContextCount: additionalParams.cwsprChatFileContextCount, cwsprChatRuleContextCount: additionalParams.cwsprChatRuleContextCount, From 7b8595a4e638562f79d5f71dcf22b0c700490458 Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:53:17 -0700 Subject: [PATCH 2/9] feat(amazonq): add consent prompt for workspace-scoped MCP servers (#2708) * feat(amazonq): add consent prompt for workspace-scoped MCP servers * fix(amazonq): suppress MCP consent re-prompts within session on deny (#2703) * test: add consent gate tests for workspace-scoped MCP servers (#2705) * fix: add missing closing brace in mcpManager.test.ts * fix: use getGlobalMcpConfigPath for cross-platform path in consent gate test * fix: addressing review feedback --------- Co-authored-by: Aseem Sharma Co-authored-by: Aseem sharma <198968351+aseemxs@users.noreply.github.com> --- .../tools/mcp/mcpConsentStore.test.ts | 153 ++++++++++++++++++ .../agenticChat/tools/mcp/mcpConsentStore.ts | 114 +++++++++++++ .../agenticChat/tools/mcp/mcpManager.test.ts | 147 +++++++++++++++++ .../agenticChat/tools/mcp/mcpManager.ts | 66 ++++++++ 4 files changed, 480 insertions(+) create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.test.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.ts diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.test.ts new file mode 100644 index 0000000000..bea38982b5 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.test.ts @@ -0,0 +1,153 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. + * All Rights Reserved. SPDX-License-Identifier: Apache-2.0 + */ + +import { expect } from 'chai' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import { fingerprintServerConfig, fingerprintWorkspace, hasApproval, recordApproval } from './mcpConsentStore' +import type { MCPServerConfig } from './mcpTypes' + +describe('mcpConsentStore', () => { + let tmpHome: string + let workspace: any + let logger: any + + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'mcpConsentTest-')) + workspace = { + fs: { + exists: (p: string) => Promise.resolve(fs.existsSync(p)), + readFile: (p: string) => Promise.resolve(Buffer.from(fs.readFileSync(p))), + writeFile: (p: string, d: string) => Promise.resolve(fs.writeFileSync(p, d)), + mkdir: (p: string, _opts: any) => Promise.resolve(fs.mkdirSync(p, { recursive: true })), + getUserHomeDir: () => tmpHome, + }, + } + logger = { warn: () => {}, info: () => {}, error: () => {} } + }) + + afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }) + }) + + describe('fingerprintServerConfig', () => { + it('is deterministic for identical config', () => { + const cfg: MCPServerConfig = { command: 'sh', args: ['-c', 'echo hi'] } + expect(fingerprintServerConfig(cfg)).to.equal(fingerprintServerConfig({ ...cfg })) + }) + + it('differs when command changes', () => { + const a: MCPServerConfig = { command: 'sh', args: ['-c', 'echo hi'] } + const b: MCPServerConfig = { command: 'bash', args: ['-c', 'echo hi'] } + expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b)) + }) + + it('differs when args change', () => { + const a: MCPServerConfig = { command: 'sh', args: ['-c', 'echo hi'] } + const b: MCPServerConfig = { command: 'sh', args: ['-c', 'echo bye'] } + expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b)) + }) + + it('differs when env changes', () => { + const a: MCPServerConfig = { command: 'sh', args: [], env: { FOO: '1' } } + const b: MCPServerConfig = { command: 'sh', args: [], env: { FOO: '2' } } + expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b)) + }) + + it('is stable regardless of env key order', () => { + const a: MCPServerConfig = { command: 'sh', args: [], env: { A: '1', B: '2' } } + const b: MCPServerConfig = { command: 'sh', args: [], env: { B: '2', A: '1' } } + expect(fingerprintServerConfig(a)).to.equal(fingerprintServerConfig(b)) + }) + + it('differs when url changes', () => { + const a: MCPServerConfig = { url: 'https://a.example' } + const b: MCPServerConfig = { url: 'https://b.example' } + expect(fingerprintServerConfig(a)).to.not.equal(fingerprintServerConfig(b)) + }) + }) + + describe('fingerprintWorkspace', () => { + it('is keyed on the directory of the config, not the filename', () => { + const a = fingerprintWorkspace('/foo/bar/.amazonq/mcp.json') + const b = fingerprintWorkspace('/foo/bar/.amazonq/agents/default.json') + // both live under /foo/bar/.amazonq's parent-dir once; path.dirname differs though + expect(a).to.not.equal(b) + }) + + it('is deterministic for the same path', () => { + const p = '/foo/bar/.amazonq/mcp.json' + expect(fingerprintWorkspace(p)).to.equal(fingerprintWorkspace(p)) + }) + }) + + describe('hasApproval / recordApproval', () => { + const cfg: MCPServerConfig = { command: 'sh', args: ['-c', 'echo ok'] } + const configPath = '/tmp/ws-a/.amazonq/mcp.json' + + it('returns false when store is empty', async () => { + expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.false + }) + + it('records and finds an approval for same (name, config, workspace)', async () => { + await recordApproval(workspace, logger, 'poc', cfg, configPath) + expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.true + }) + + it('does not match when workspace path differs', async () => { + await recordApproval(workspace, logger, 'poc', cfg, '/tmp/ws-a/.amazonq/mcp.json') + expect(await hasApproval(workspace, logger, 'poc', cfg, '/tmp/ws-b/.amazonq/mcp.json')).to.be.false + }) + + it('does not match when command changes (fingerprint invalidates)', async () => { + await recordApproval(workspace, logger, 'poc', cfg, configPath) + const mutated: MCPServerConfig = { command: 'sh', args: ['-c', 'curl evil'] } + expect(await hasApproval(workspace, logger, 'poc', mutated, configPath)).to.be.false + }) + + it('does not match when server name differs', async () => { + await recordApproval(workspace, logger, 'poc', cfg, configPath) + expect(await hasApproval(workspace, logger, 'other', cfg, configPath)).to.be.false + }) + + it('dedupes repeated approvals for the same key', async () => { + await recordApproval(workspace, logger, 'poc', cfg, configPath) + await recordApproval(workspace, logger, 'poc', cfg, configPath) + const stored = JSON.parse( + fs.readFileSync(path.join(tmpHome, '.aws', 'amazonq', 'mcp-approvals.json')).toString() + ) + expect(stored.approvals).to.have.lengthOf(1) + }) + + it('evicts stale entry when config changes for same server and workspace', async () => { + await recordApproval(workspace, logger, 'poc', cfg, configPath) + const mutated: MCPServerConfig = { command: 'sh', args: ['-c', 'echo changed'] } + await recordApproval(workspace, logger, 'poc', mutated, configPath) + const stored = JSON.parse( + fs.readFileSync(path.join(tmpHome, '.aws', 'amazonq', 'mcp-approvals.json')).toString() + ) + // Should have exactly 1 entry — the old fingerprint was evicted + expect(stored.approvals).to.have.lengthOf(1) + expect(stored.approvals[0].fingerprint).to.equal(fingerprintServerConfig(mutated)) + }) + + it('ignores a store with unrecognized version', async () => { + const storeDir = path.join(tmpHome, '.aws', 'amazonq') + fs.mkdirSync(storeDir, { recursive: true }) + fs.writeFileSync(path.join(storeDir, 'mcp-approvals.json'), JSON.stringify({ version: 999, approvals: [] })) + // record should still work (overwrites with v1) + await recordApproval(workspace, logger, 'poc', cfg, configPath) + expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.true + }) + + it('treats a malformed store as empty', async () => { + const storeDir = path.join(tmpHome, '.aws', 'amazonq') + fs.mkdirSync(storeDir, { recursive: true }) + fs.writeFileSync(path.join(storeDir, 'mcp-approvals.json'), 'not json') + expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.false + }) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.ts new file mode 100644 index 0000000000..05b64cf9d4 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.ts @@ -0,0 +1,114 @@ +/** + * Copyright Amazon.com, Inc. or its affiliates. + * All Rights Reserved. SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'crypto' +import * as path from 'path' +import type { Workspace, Logging } from '@aws/language-server-runtimes/server-interface' +import type { MCPServerConfig } from './mcpTypes' + +const APPROVALS_FILE = 'mcp-approvals.json' +const STORE_VERSION = 1 + +interface Approval { + serverName: string + fingerprint: string + workspaceHash: string + approvedAt: string +} + +interface ApprovalStore { + version: number + approvals: Approval[] +} + +/** + * SHA-256 of a canonical JSON form of the server's execution-relevant fields. + * Any change to command/args/env/url yields a new fingerprint, invalidating + * prior approvals — so mutation of the config re-prompts. + */ +export function fingerprintServerConfig(cfg: MCPServerConfig): string { + const canonical = { + command: cfg.command ?? null, + args: cfg.args ?? [], + env: cfg.env ? Object.fromEntries(Object.entries(cfg.env).sort(([a], [b]) => a.localeCompare(b))) : {}, + url: cfg.url ?? null, + } + return 'sha256:' + createHash('sha256').update(JSON.stringify(canonical)).digest('hex') +} + +/** Hash of the workspace path so approval is scoped to (workspace, config). + * Normalizes the path to forward slashes for cross-platform consistency. */ +export function fingerprintWorkspace(configPath: string): string { + const normalized = path.resolve(path.dirname(configPath)).replace(/\\/g, '/') + return 'sha256:' + createHash('sha256').update(normalized).digest('hex') +} + +function getStorePath(workspace: Workspace): string { + return path.join(workspace.fs.getUserHomeDir(), '.aws', 'amazonq', APPROVALS_FILE) +} + +async function readStore(workspace: Workspace, logging: Logging): Promise { + const file = getStorePath(workspace) + try { + if (!(await workspace.fs.exists(file))) { + return { version: STORE_VERSION, approvals: [] } + } + const raw = (await workspace.fs.readFile(file)).toString() + const parsed = JSON.parse(raw) as ApprovalStore + if (parsed?.version !== STORE_VERSION || !Array.isArray(parsed.approvals)) { + logging.warn(`MCP consent store: unrecognized format at ${file}, treating as empty`) + return { version: STORE_VERSION, approvals: [] } + } + return parsed + } catch (e: any) { + logging.warn(`MCP consent store: failed to read ${file}: ${e?.message}`) + return { version: STORE_VERSION, approvals: [] } + } +} + +async function writeStore(workspace: Workspace, logging: Logging, store: ApprovalStore): Promise { + const file = getStorePath(workspace) + try { + await workspace.fs.mkdir(path.dirname(file), { recursive: true }) + await workspace.fs.writeFile(file, JSON.stringify(store, null, 2)) + } catch (e: any) { + logging.warn(`MCP consent store: failed to write ${file}: ${e?.message}`) + } +} + +export async function hasApproval( + workspace: Workspace, + logging: Logging, + serverName: string, + cfg: MCPServerConfig, + configPath: string +): Promise { + const store = await readStore(workspace, logging) + const fp = fingerprintServerConfig(cfg) + const wh = fingerprintWorkspace(configPath) + return store.approvals.some(a => a.serverName === serverName && a.fingerprint === fp && a.workspaceHash === wh) +} + +export async function recordApproval( + workspace: Workspace, + logging: Logging, + serverName: string, + cfg: MCPServerConfig, + configPath: string +): Promise { + const store = await readStore(workspace, logging) + const fp = fingerprintServerConfig(cfg) + const wh = fingerprintWorkspace(configPath) + // Replace any prior approval for the same (server, workspace) — this evicts + // stale entries when the config changes (fingerprint differs). + store.approvals = store.approvals.filter(a => !(a.serverName === serverName && a.workspaceHash === wh)) + store.approvals.push({ + serverName, + fingerprint: fp, + workspaceHash: wh, + approvedAt: new Date().toISOString(), + }) + await writeStore(workspace, logging, store) +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts index 170abcc3db..0667d149cc 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts @@ -1936,3 +1936,150 @@ describe('addRegistryServer with additional headers/env', () => { expect(agentCfg.env).to.be.undefined }) }) + +describe('consent gate for workspace-scoped MCP servers (P417451767)', () => { + const fakeHome = '/home/testuser' + const globalMcp = mcpUtils.getGlobalMcpConfigPath(fakeHome) + const workspaceMcp = '/tmp/ws-a/.amazonq/mcp.json' + + let showMessageStub: sinon.SinonStub + let hasApprovalStub: sinon.SinonStub + let recordApprovalStub: sinon.SinonStub + let setStateSpy: sinon.SinonSpy + + async function buildMgr(): Promise { + const consentStore = require('./mcpConsentStore') + hasApprovalStub = sinon.stub(consentStore, 'hasApproval').resolves(false) + recordApprovalStub = sinon.stub(consentStore, 'recordApproval').resolves() + + showMessageStub = sinon.stub() + const featuresWithPrompt = { + ...features, + workspace: { + ...fakeWorkspace, + fs: { ...fakeWorkspace.fs, getUserHomeDir: () => fakeHome }, + }, + lsp: { window: { showMessageRequest: showMessageStub } }, + } + sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map(), + serverNameMapping: new Map(), + errors: new Map(), + agentConfig: { + name: 'test', + description: '', + mcpServers: {}, + tools: [], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + const mgr = await McpManager.init([], featuresWithPrompt as any) + setStateSpy = sinon.spy(mgr as any, 'setState') + return mgr + } + + afterEach(async () => { + sinon.restore() + try { + await McpManager.instance.close() + } catch {} + }) + + it('does not prompt for global-scoped config', async () => { + const mgr = await buildMgr() + const cfg: MCPServerConfig = { command: 'sh', args: [], __configPath__: globalMcp } + // Fail fast after gate (cleanupExistingServer is safe to call on unknown server) + try { + await (mgr as any).initOneServerInternal('svc', cfg) + } catch {} + expect(showMessageStub.called).to.be.false + }) + + it('does not prompt for global agent config path', async () => { + const mgr = await buildMgr() + const globalAgent = mcpUtils.getGlobalAgentConfigPath(fakeHome) + const cfg: MCPServerConfig = { command: 'sh', args: [], __configPath__: globalAgent } + try { + await (mgr as any).initOneServerInternal('svc', cfg) + } catch {} + expect(showMessageStub.called).to.be.false + }) + + it('does not prompt for global persona config path', async () => { + const mgr = await buildMgr() + const globalPersona = mcpUtils.getGlobalPersonaConfigPath(fakeHome) + const cfg: MCPServerConfig = { command: 'sh', args: [], __configPath__: globalPersona } + try { + await (mgr as any).initOneServerInternal('svc', cfg) + } catch {} + expect(showMessageStub.called).to.be.false + }) + + it('prompts for workspace-scoped config when no prior approval', async () => { + const mgr = await buildMgr() + showMessageStub.resolves({ title: 'Deny' }) + const cfg: MCPServerConfig = { command: 'sh', args: ['-c', 'x'], __configPath__: workspaceMcp } + try { + await (mgr as any).initOneServerInternal('svc', cfg) + } catch {} + expect(showMessageStub.calledOnce).to.be.true + }) + + it('denial sets DISABLED state and caches the decision', async () => { + const mgr = await buildMgr() + showMessageStub.resolves({ title: 'Deny' }) + const cfg: MCPServerConfig = { command: 'sh', args: ['-c', 'x'], __configPath__: workspaceMcp } + try { + await (mgr as any).initOneServerInternal('svc', cfg) + } catch {} + expect(setStateSpy.calledWith('svc', McpServerStatus.DISABLED, 0, 'consent not granted')).to.be.true + + // Second call with same cfg should not re-prompt + showMessageStub.resetHistory() + try { + await (mgr as any).initOneServerInternal('svc', cfg) + } catch {} + expect(showMessageStub.called).to.be.false + }) + + it('mutation of args invalidates session denial (fingerprint change)', async () => { + const mgr = await buildMgr() + showMessageStub.resolves({ title: 'Deny' }) + const cfg1: MCPServerConfig = { command: 'sh', args: ['-c', 'x'], __configPath__: workspaceMcp } + try { + await (mgr as any).initOneServerInternal('svc', cfg1) + } catch {} + expect(showMessageStub.calledOnce).to.be.true + + // Mutate args — fingerprint changes, denial cache key differs, prompt should fire again + showMessageStub.resetHistory() + const cfg2: MCPServerConfig = { command: 'sh', args: ['-c', 'y'], __configPath__: workspaceMcp } + try { + await (mgr as any).initOneServerInternal('svc', cfg2) + } catch {} + expect(showMessageStub.calledOnce).to.be.true + }) + + it('prior approval short-circuits prompt', async () => { + const mgr = await buildMgr() + hasApprovalStub.resolves(true) + const cfg: MCPServerConfig = { command: 'sh', args: [], __configPath__: workspaceMcp } + try { + await (mgr as any).initOneServerInternal('svc', cfg) + } catch {} + expect(showMessageStub.called).to.be.false + }) + + it('allow records approval', async () => { + const mgr = await buildMgr() + showMessageStub.resolves({ title: 'Allow for this server' }) + const cfg: MCPServerConfig = { command: 'sh', args: ['-c', 'x'], __configPath__: workspaceMcp } + try { + await (mgr as any).initOneServerInternal('svc', cfg) + } catch {} + expect(recordApprovalStub.calledOnce).to.be.true + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts index f5ab29d946..679a4207ea 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts @@ -33,12 +33,15 @@ import { getGlobalAgentConfigPath, getWorkspaceMcpConfigPaths, getGlobalMcpConfigPath, + getGlobalPersonaConfigPath, } from './mcpUtils' import { AgenticChatError } from '../../errors' import { EventEmitter } from 'events' import { Mutex } from 'async-mutex' import path = require('path') import { URI } from 'vscode-uri' +import { MessageType } from '@aws/language-server-runtimes/protocol' +import { hasApproval, recordApproval, fingerprintServerConfig } from './mcpConsentStore' import { sanitizeInput } from '../../../../shared/utils' import { ProfileStatusMonitor } from './profileStatusMonitor' import { OAuthClient } from './mcpOauthClient' @@ -78,6 +81,7 @@ export class McpManager { private currentRegistry: McpRegistryData | null = null private registryUrlProvided: boolean = false private isPeriodicSync: boolean = false + private sessionDeniedConsent = new Set() private constructor( private agentPaths: string[], @@ -408,6 +412,67 @@ export class McpManager { ): Promise { const DEFAULT_SERVER_INIT_TIMEOUT_MS = 120_000 + // Consent gate for workspace-scoped MCP configs (P417451767). + // Workspace-scoped configs live in a folder the user opened and may be attacker-controlled. + // Global configs (~/.aws/amazonq/...) are user-authored and trusted implicitly. + const home = this.features.workspace.fs.getUserHomeDir() + const configPath = cfg.__configPath__ + const globalMcp = getGlobalMcpConfigPath(home) + const globalAgent = getGlobalAgentConfigPath(home) + const globalPersona = getGlobalPersonaConfigPath(home) + const isWorkspaceScoped = + !!configPath && configPath !== globalMcp && configPath !== globalAgent && configPath !== globalPersona + if (isWorkspaceScoped && configPath) { + const denyKey = `${serverName}|${configPath}|${fingerprintServerConfig(cfg)}` + if (this.sessionDeniedConsent.has(denyKey)) { + this.setState(serverName, McpServerStatus.DISABLED, 0, 'consent not granted') + return + } + const approved = await hasApproval( + this.features.workspace, + this.features.logging, + serverName, + cfg, + configPath + ) + if (!approved) { + const cmdLine = [cfg.command ?? cfg.url ?? '(none)', ...(cfg.args ?? [])].join(' ').slice(0, 200) + const allowBtn = { title: 'Allow for this server' } + const denyBtn = { title: 'Deny' } + let choice: { title: string } | null | undefined + try { + choice = await this.features.lsp.window.showMessageRequest({ + type: MessageType.Warning, + message: + `Amazon Q — Untrusted MCP Server\n\n` + + `A workspace configuration file wants to start an MCP server.\n` + + `Server: ${serverName}\n` + + `Command: ${cmdLine}\n` + + `Source: ${configPath}\n\n` + + `Running this server executes the above command on your machine. ` + + `Only allow if you trust the authors of this workspace.\n\n` + + `Your choice will be remembered for this workspace. ` + + `If you allow, you won't be asked again unless the server configuration changes.`, + actions: [allowBtn, denyBtn], + }) + } catch (e: any) { + this.features.logging.warn(`MCP: consent prompt failed for '${serverName}': ${e?.message}`) + this.setState(serverName, McpServerStatus.FAILED, 0, 'consent prompt failed') + return + } + if (choice?.title !== allowBtn.title) { + this.features.logging.info( + `MCP: user declined consent for workspace-scoped server '${serverName}' (response: ${choice?.title ?? 'dismissed'})` + ) + this.sessionDeniedConsent.add(denyKey) + this.setState(serverName, McpServerStatus.DISABLED, 0, 'consent not granted') + return + } + await recordApproval(this.features.workspace, this.features.logging, serverName, cfg, configPath) + this.features.logging.info(`MCP: recorded consent for workspace-scoped server '${serverName}'`) + } + } + // Lightweight cleanup - only kill our tracked processes await this.cleanupExistingServer(serverName) @@ -1285,6 +1350,7 @@ export class McpManager { this.mcpTools = [] this.mcpServers.clear() this.mcpServerStates.clear() + this.sessionDeniedConsent.clear() this.agentConfig = { name: 'q_ide_default', description: 'Agent configuration', From 4a4210a11f9f7021a917d4afebc1e6627397faa5 Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:03:25 -0700 Subject: [PATCH 3/9] chore: bumpup language server runtime version (#2709) --- chat-client/package.json | 2 +- package-lock.json | 10 +++++----- server/aws-lsp-codewhisperer/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/chat-client/package.json b/chat-client/package.json index d6dcc0c496..15bb68c5e1 100644 --- a/chat-client/package.json +++ b/chat-client/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@aws/chat-client-ui-types": "0.1.68", - "@aws/language-server-runtimes": "^0.3.16", + "@aws/language-server-runtimes": "^0.3.17", "@aws/language-server-runtimes-types": "^0.1.64", "@aws/mynah-ui": "^4.40.1" }, diff --git a/package-lock.json b/package-lock.json index 5a47697e47..e26ca5cb9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -255,7 +255,7 @@ "license": "Apache-2.0", "dependencies": { "@aws/chat-client-ui-types": "0.1.68", - "@aws/language-server-runtimes": "^0.3.16", + "@aws/language-server-runtimes": "^0.3.17", "@aws/language-server-runtimes-types": "^0.1.64", "@aws/mynah-ui": "^4.40.1" }, @@ -4506,9 +4506,9 @@ } }, "node_modules/@aws/language-server-runtimes": { - "version": "0.3.16", - "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.3.16.tgz", - "integrity": "sha512-i5Rlnq1VUWpihGyd65o5gRqA8rxnkWZkx0WLsBCpuD9Lpztscwq2Si6f1dhhKK59905nG/xNE1xvRVAlXxc0IA==", + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.3.17.tgz", + "integrity": "sha512-yA7A7o5YChUlOT0zip9vGQu2Q5+UnHW/39cn7LKsTH0VD8ZiB8I8/4SXUggV3as2Vy7nD447xJGVkqjYqlngRA==", "license": "Apache-2.0", "dependencies": { "@aws/language-server-runtimes-types": "^0.1.64", @@ -31122,7 +31122,7 @@ "@aws-sdk/util-arn-parser": "^3.723.0", "@aws-sdk/util-retry": "^3.374.0", "@aws/chat-client-ui-types": "0.1.68", - "@aws/language-server-runtimes": "^0.3.16", + "@aws/language-server-runtimes": "^0.3.17", "@aws/lsp-core": "^0.0.21", "@modelcontextprotocol/sdk": "^1.23.0", "@mozilla/readability": "^0.6.0", diff --git a/server/aws-lsp-codewhisperer/package.json b/server/aws-lsp-codewhisperer/package.json index 269aabbfa8..621565964e 100644 --- a/server/aws-lsp-codewhisperer/package.json +++ b/server/aws-lsp-codewhisperer/package.json @@ -38,7 +38,7 @@ "@aws-sdk/util-arn-parser": "^3.723.0", "@aws-sdk/util-retry": "^3.374.0", "@aws/chat-client-ui-types": "0.1.68", - "@aws/language-server-runtimes": "^0.3.16", + "@aws/language-server-runtimes": "^0.3.17", "@aws/lsp-core": "^0.0.21", "@modelcontextprotocol/sdk": "^1.23.0", "@mozilla/readability": "^0.6.0", From f5aa1a3b25aa38bfe8dd0e830b5839e1cea1d410 Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:37:44 -0700 Subject: [PATCH 4/9] fix(amazonq): improve MCP consent gate reliability and cleanup (#2711) * feat(amazonq): add consent prompt for workspace-scoped MCP servers * fix(amazonq): suppress MCP consent re-prompts within session on deny (#2703) * test: add consent gate tests for workspace-scoped MCP servers (#2705) * fix: add missing closing brace in mcpManager.test.ts * fix: use getGlobalMcpConfigPath for cross-platform path in consent gate test * fix: addressing review feedback * fix(amazonq): improve MCP consent gate reliability and cleanup - Fix spurious re-prompts on IDE reload by using OR matching in hasApproval: match on (serverName, fingerprint) OR (serverName, workspaceHash). The fingerprint can change slightly between reloads due to config migration, and the workspaceHash covers that case. - Add getGlobalPersonaConfigPath to the trusted set so persona configs don't trigger unnecessary consent prompts. - Clear sessionDeniedConsent in close() for consistency with other state. - Replace non-null assertion with inline Set initialization. - Evict stale approval entries when config changes for the same server and workspace instead of accumulating them. - Normalize configPath via normalizePathFromUri before consent checks. - Add removeApproval and call it from removeServer so deleting a workspace MCP server clears its persisted approval. - Improve prompt text to explain persistence semantics to the user. - Normalize paths with path.resolve and forward slashes for cross- platform consistency in fingerprintWorkspace. --------- Co-authored-by: Aseem Sharma Co-authored-by: Aseem sharma <198968351+aseemxs@users.noreply.github.com> --- .../tools/mcp/mcpConsentStore.test.ts | 38 ++++++++++++++++--- .../agenticChat/tools/mcp/mcpConsentStore.ts | 23 ++++++++++- .../agenticChat/tools/mcp/mcpManager.ts | 11 +++++- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.test.ts index bea38982b5..bf3fdb47c9 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.test.ts @@ -7,7 +7,13 @@ import { expect } from 'chai' import * as fs from 'fs' import * as os from 'os' import * as path from 'path' -import { fingerprintServerConfig, fingerprintWorkspace, hasApproval, recordApproval } from './mcpConsentStore' +import { + fingerprintServerConfig, + fingerprintWorkspace, + hasApproval, + recordApproval, + removeApproval, +} from './mcpConsentStore' import type { MCPServerConfig } from './mcpTypes' describe('mcpConsentStore', () => { @@ -97,15 +103,23 @@ describe('mcpConsentStore', () => { expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.true }) - it('does not match when workspace path differs', async () => { + it('matches via fingerprint even when workspace path differs', async () => { await recordApproval(workspace, logger, 'poc', cfg, '/tmp/ws-a/.amazonq/mcp.json') - expect(await hasApproval(workspace, logger, 'poc', cfg, '/tmp/ws-b/.amazonq/mcp.json')).to.be.false + expect(await hasApproval(workspace, logger, 'poc', cfg, '/tmp/ws-b/.amazonq/mcp.json')).to.be.true }) - it('does not match when command changes (fingerprint invalidates)', async () => { + it('matches via workspaceHash even when fingerprint differs', async () => { + await recordApproval(workspace, logger, 'poc', cfg, configPath) + const mutated: MCPServerConfig = { command: 'sh', args: ['-c', 'echo different'] } + // Same workspace, different fingerprint — should still match via workspaceHash fallback + expect(await hasApproval(workspace, logger, 'poc', mutated, configPath)).to.be.true + }) + + it('does not match when both fingerprint and workspace differ', async () => { await recordApproval(workspace, logger, 'poc', cfg, configPath) const mutated: MCPServerConfig = { command: 'sh', args: ['-c', 'curl evil'] } - expect(await hasApproval(workspace, logger, 'poc', mutated, configPath)).to.be.false + // Different fingerprint AND different workspace — no match + expect(await hasApproval(workspace, logger, 'poc', mutated, '/tmp/ws-other/.amazonq/mcp.json')).to.be.false }) it('does not match when server name differs', async () => { @@ -149,5 +163,19 @@ describe('mcpConsentStore', () => { fs.writeFileSync(path.join(storeDir, 'mcp-approvals.json'), 'not json') expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.false }) + + it('removeApproval clears a previously recorded approval', async () => { + await recordApproval(workspace, logger, 'poc', cfg, configPath) + expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.true + await removeApproval(workspace, logger, 'poc', configPath) + expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.false + }) + + it('removeApproval is a no-op when no matching server name exists', async () => { + await recordApproval(workspace, logger, 'poc', cfg, configPath) + await removeApproval(workspace, logger, 'other', configPath) + // Original approval should still be there + expect(await hasApproval(workspace, logger, 'poc', cfg, configPath)).to.be.true + }) }) }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.ts index 05b64cf9d4..349cd32114 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpConsentStore.ts @@ -88,7 +88,13 @@ export async function hasApproval( const store = await readStore(workspace, logging) const fp = fingerprintServerConfig(cfg) const wh = fingerprintWorkspace(configPath) - return store.approvals.some(a => a.serverName === serverName && a.fingerprint === fp && a.workspaceHash === wh) + // Primary match: (serverName, fingerprint) — the fingerprint captures the full + // execution-relevant config (command/args/env/url). This works even if the + // workspaceHash varies between reloads due to configPath format differences. + // Fallback match: (serverName, workspaceHash) — covers cases where the + // fingerprint changes slightly between reloads (e.g., config migration adds + // default values) but the workspace is the same. + return store.approvals.some(a => a.serverName === serverName && (a.fingerprint === fp || a.workspaceHash === wh)) } export async function recordApproval( @@ -112,3 +118,18 @@ export async function recordApproval( }) await writeStore(workspace, logging, store) } + +export async function removeApproval( + workspace: Workspace, + logging: Logging, + serverName: string, + configPath: string +): Promise { + const store = await readStore(workspace, logging) + const before = store.approvals.length + store.approvals = store.approvals.filter(a => a.serverName !== serverName) + if (store.approvals.length < before) { + await writeStore(workspace, logging, store) + logging.info(`MCP consent store: removed approval for '${serverName}'`) + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts index 679a4207ea..61e7ea75fe 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts @@ -34,6 +34,7 @@ import { getWorkspaceMcpConfigPaths, getGlobalMcpConfigPath, getGlobalPersonaConfigPath, + normalizePathFromUri, } from './mcpUtils' import { AgenticChatError } from '../../errors' import { EventEmitter } from 'events' @@ -41,7 +42,7 @@ import { Mutex } from 'async-mutex' import path = require('path') import { URI } from 'vscode-uri' import { MessageType } from '@aws/language-server-runtimes/protocol' -import { hasApproval, recordApproval, fingerprintServerConfig } from './mcpConsentStore' +import { hasApproval, recordApproval, removeApproval, fingerprintServerConfig } from './mcpConsentStore' import { sanitizeInput } from '../../../../shared/utils' import { ProfileStatusMonitor } from './profileStatusMonitor' import { OAuthClient } from './mcpOauthClient' @@ -417,6 +418,8 @@ export class McpManager { // Global configs (~/.aws/amazonq/...) are user-authored and trusted implicitly. const home = this.features.workspace.fs.getUserHomeDir() const configPath = cfg.__configPath__ + ? normalizePathFromUri(cfg.__configPath__, this.features.logging) + : undefined const globalMcp = getGlobalMcpConfigPath(home) const globalAgent = getGlobalAgentConfigPath(home) const globalPersona = getGlobalPersonaConfigPath(home) @@ -1138,6 +1141,12 @@ export class McpManager { this.mcpTools = this.mcpTools.filter(t => t.serverName !== serverName) this.mcpServerStates.delete(serverName) + // Clean up any persisted consent approval for this server + if (cfg.__configPath__) { + const normalizedPath = normalizePathFromUri(cfg.__configPath__, this.features.logging) + await removeApproval(this.features.workspace, this.features.logging, serverName, normalizedPath) + } + // Check if this is a legacy MCP server (from MCP config file) const isLegacyMcpServer = cfg.__configPath__?.endsWith('mcp.json') let agentPath: string | undefined From 71c53d10a14c154e1790c622ffaa3f476f15fc22 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:20:22 -0700 Subject: [PATCH 5/9] chore(release): release packages from branch main (#2699) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- package-lock.json | 2 +- server/aws-lsp-codewhisperer/CHANGELOG.md | 14 ++++++++++++++ server/aws-lsp-codewhisperer/package.json | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 9ae14a3a11..961de9e84a 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,7 +2,7 @@ "chat-client": "0.1.51", "core/aws-lsp-core": "0.0.21", "server/aws-lsp-antlr4": "0.1.25", - "server/aws-lsp-codewhisperer": "0.0.112", + "server/aws-lsp-codewhisperer": "0.0.113", "server/aws-lsp-json": "0.1.26", "server/aws-lsp-partiql": "0.0.23", "server/aws-lsp-yaml": "0.1.26" diff --git a/package-lock.json b/package-lock.json index e26ca5cb9e..74df6617dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31102,7 +31102,7 @@ }, "server/aws-lsp-codewhisperer": { "name": "@aws/lsp-codewhisperer", - "version": "0.0.112", + "version": "0.0.113", "bundleDependencies": [ "@amzn/codewhisperer", "@amzn/codewhisperer-runtime", diff --git a/server/aws-lsp-codewhisperer/CHANGELOG.md b/server/aws-lsp-codewhisperer/CHANGELOG.md index 059db3379a..08bf1c874b 100644 --- a/server/aws-lsp-codewhisperer/CHANGELOG.md +++ b/server/aws-lsp-codewhisperer/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.0.113](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.112...lsp-codewhisperer/v0.0.113) (2026-04-29) + + +### Features + +* **amazonq:** add consent prompt for workspace-scoped MCP servers ([#2708](https://github.com/aws/language-servers/issues/2708)) ([7b8595a](https://github.com/aws/language-servers/commit/7b8595a4e638562f79d5f71dcf22b0c700490458)) + + +### Bug Fixes + +* **amazonq:** improve MCP consent gate reliability and cleanup ([#2711](https://github.com/aws/language-servers/issues/2711)) ([f5aa1a3](https://github.com/aws/language-servers/commit/f5aa1a3b25aa38bfe8dd0e830b5839e1cea1d410)) +* deprecate [@workspace](https://github.com/workspace) vector search + fix [@folder](https://github.com/folder) files not appearing in context ([#2698](https://github.com/aws/language-servers/issues/2698)) ([ae7d3fc](https://github.com/aws/language-servers/commit/ae7d3fcd26f57d6cc5d3d26dd5ec79983c4103df)) +* guard workspaceFolderManager null reference in updateConfiguration ([#2695](https://github.com/aws/language-servers/issues/2695)) ([dcd7829](https://github.com/aws/language-servers/commit/dcd78298766d09902ba51cb12547780f518d48a9)) + ## [0.0.112](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.111...lsp-codewhisperer/v0.0.112) (2026-04-07) diff --git a/server/aws-lsp-codewhisperer/package.json b/server/aws-lsp-codewhisperer/package.json index 621565964e..325d183d16 100644 --- a/server/aws-lsp-codewhisperer/package.json +++ b/server/aws-lsp-codewhisperer/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-codewhisperer", - "version": "0.0.112", + "version": "0.0.113", "description": "CodeWhisperer Language Server", "main": "out/index.js", "repository": { From b6226e758d52d8db85c844cab82e6b604566e2ef Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Tue, 5 May 2026 10:52:03 -0700 Subject: [PATCH 6/9] fix(amazonq): route inline chat through getChatResponse for correct API selection (#2713) (#2714) The onInlineChatPrompt handler was hardcoded to call client.sendMessage() regardless of the authentication type. This caused Kiro Enterprise subscription users on Eclipse to get Your subscription does not support this application errors because SendMessage is not in the Kiro Enterprise API allowlist. Route inline chat through getChatResponse() which correctly selects GenerateAssistantResponse for token-based (SSO/IdC) clients and SendMessage only for IAM clients, matching the behavior of regular chat. --- .../agenticChat/agenticChatController.test.ts | 25 +++++++++++-------- .../agenticChat/agenticChatController.ts | 22 ++++++++-------- 2 files changed, 26 insertions(+), 21 deletions(-) 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 a6bcc00404..7f90c54fb6 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 @@ -2159,8 +2159,8 @@ describe('AgenticChatController', () => { assert.deepStrictEqual(chatResult, expectedCompleteInlineChatResult) }) - it('returns a ResponseError if sendMessage returns an error', async () => { - sendMessageStub.callsFake(() => { + it('returns a ResponseError if generateAssistantResponse returns an error', async () => { + generateAssistantResponseStub.callsFake(() => { throw new Error('Error') }) @@ -2172,8 +2172,8 @@ describe('AgenticChatController', () => { assert.ok(chatResult instanceof ResponseError) }) - it('returns a Response error if sendMessage returns an auth error', async () => { - sendMessageStub.callsFake(() => { + it('returns a Response error if generateAssistantResponse returns an auth error', async () => { + generateAssistantResponseStub.callsFake(() => { throw new Error('Error') }) @@ -2189,12 +2189,12 @@ describe('AgenticChatController', () => { }) it('returns a ResponseError if response streams return an error event', async () => { - sendMessageStub.callsFake(() => { + generateAssistantResponseStub.callsFake(() => { return Promise.resolve({ $metadata: { requestId: mockMessageId, }, - sendMessageResponse: createIterableResponse([ + generateAssistantResponseResponse: createIterableResponse([ // ["Hello ", "World"] ...mockChatResponseList.slice(1, 3), { error: { message: 'some error' } }, @@ -2213,12 +2213,12 @@ describe('AgenticChatController', () => { }) it('returns a ResponseError if response streams return an invalid state event', async () => { - sendMessageStub.callsFake(() => { + generateAssistantResponseStub.callsFake(() => { return Promise.resolve({ $metadata: { requestId: mockMessageId, }, - sendMessageResponse: createIterableResponse([ + generateAssistantResponseResponse: createIterableResponse([ // ["Hello ", "World"] ...mockChatResponseList.slice(1, 3), { invalidStateEvent: { message: 'invalid state' } }, @@ -2279,7 +2279,8 @@ describe('AgenticChatController', () => { mockCancellationToken ) - const calledRequestInput: SendMessageCommandInput = sendMessageStub.firstCall.firstArg + const calledRequestInput: GenerateAssistantResponseCommandInput = + generateAssistantResponseStub.firstCall.firstArg assert.strictEqual( calledRequestInput.conversationState?.currentMessage?.userInputMessage?.userInputMessageContext @@ -2304,7 +2305,8 @@ describe('AgenticChatController', () => { mockCancellationToken ) - const calledRequestInput: SendMessageCommandInput = sendMessageStub.firstCall.firstArg + const calledRequestInput: GenerateAssistantResponseCommandInput = + generateAssistantResponseStub.firstCall.firstArg assert.strictEqual( calledRequestInput.conversationState?.currentMessage?.userInputMessage?.userInputMessageContext @@ -2330,7 +2332,8 @@ describe('AgenticChatController', () => { mockCancellationToken ) - const calledRequestInput: SendMessageCommandInput = sendMessageStub.firstCall.firstArg + const calledRequestInput: GenerateAssistantResponseCommandInput = + generateAssistantResponseStub.firstCall.firstArg assert.deepStrictEqual( calledRequestInput.conversationState?.currentMessage?.userInputMessage?.userInputMessageContext 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 a3384faf17..6a8846c3cb 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -37,12 +37,7 @@ import { SUFFIX_UNDOALL, SUFFIX_EXPLANATION, } from './constants/toolConstants' -import { - SendMessageCommandInput, - SendMessageCommandOutput, - ChatCommandInput, - ChatCommandOutput, -} from '../../shared/streamingClientService' +import { SendMessageCommandInput, ChatCommandInput, ChatCommandOutput } from '../../shared/streamingClientService' import { Button, Status, @@ -3776,8 +3771,8 @@ export class AgenticChatController implements ChatHandlers { throw new Error('amazonQServiceManager is not initialized') } - const client = this.#serviceManager.getStreamingClient() - response = await client.sendMessage(requestInput as SendMessageCommandInput) + const session = new ChatSessionService(this.#serviceManager, this.#features.lsp, this.#features.logging) + response = await session.getChatResponse(requestInput) this.#log('Response for inline chat', JSON.stringify(response.$metadata), JSON.stringify(response)) } catch (err) { if (err instanceof AmazonQServicePendingSigninError || err instanceof AmazonQServicePendingProfileError) { @@ -4697,14 +4692,21 @@ export class AgenticChatController implements ChatHandlers { } async #processSendMessageResponseForInlineChat( - response: SendMessageCommandOutput, + response: ChatCommandOutput, metric: Metric, partialResultToken?: string | number ): Promise> { const requestId = response.$metadata.requestId! const chatEventParser = new ChatEventParser(requestId, metric) - for await (const chatEvent of response.sendMessageResponse!) { + let chatEventStream = undefined + if ('generateAssistantResponseResponse' in response) { + chatEventStream = response.generateAssistantResponseResponse + } else if ('sendMessageResponse' in response) { + chatEventStream = response.sendMessageResponse + } + + for await (const chatEvent of chatEventStream!) { const result = chatEventParser.processPartialEvent(chatEvent) // terminate early when there is an error From f9753836aab31661ef1bebb4019743d2bada807b Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Tue, 5 May 2026 10:53:36 -0700 Subject: [PATCH 7/9] chore: bump agentic version: 1.65.0 (#2712) Co-authored-by: aws-toolkit-automation <> --- app/aws-lsp-codewhisperer-runtimes/src/version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/aws-lsp-codewhisperer-runtimes/src/version.json b/app/aws-lsp-codewhisperer-runtimes/src/version.json index 430db61483..95fcdb7741 100644 --- a/app/aws-lsp-codewhisperer-runtimes/src/version.json +++ b/app/aws-lsp-codewhisperer-runtimes/src/version.json @@ -1,3 +1,3 @@ { - "agenticChat": "1.64.0" + "agenticChat": "1.65.0" } From 8f2a34b09fb984e587f02a04f3ea0b0470efe449 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 11:31:13 -0700 Subject: [PATCH 8/9] chore(release): release packages from branch main (#2716) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- package-lock.json | 2 +- server/aws-lsp-codewhisperer/CHANGELOG.md | 7 +++++++ server/aws-lsp-codewhisperer/package.json | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 961de9e84a..8c80b00b09 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,7 +2,7 @@ "chat-client": "0.1.51", "core/aws-lsp-core": "0.0.21", "server/aws-lsp-antlr4": "0.1.25", - "server/aws-lsp-codewhisperer": "0.0.113", + "server/aws-lsp-codewhisperer": "0.0.114", "server/aws-lsp-json": "0.1.26", "server/aws-lsp-partiql": "0.0.23", "server/aws-lsp-yaml": "0.1.26" diff --git a/package-lock.json b/package-lock.json index 74df6617dd..295b7915b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31102,7 +31102,7 @@ }, "server/aws-lsp-codewhisperer": { "name": "@aws/lsp-codewhisperer", - "version": "0.0.113", + "version": "0.0.114", "bundleDependencies": [ "@amzn/codewhisperer", "@amzn/codewhisperer-runtime", diff --git a/server/aws-lsp-codewhisperer/CHANGELOG.md b/server/aws-lsp-codewhisperer/CHANGELOG.md index 08bf1c874b..6a6b4e16f3 100644 --- a/server/aws-lsp-codewhisperer/CHANGELOG.md +++ b/server/aws-lsp-codewhisperer/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.0.114](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.113...lsp-codewhisperer/v0.0.114) (2026-05-05) + + +### Bug Fixes + +* **amazonq:** route inline chat through getChatResponse for correct API selection ([#2713](https://github.com/aws/language-servers/issues/2713)) ([#2714](https://github.com/aws/language-servers/issues/2714)) ([b6226e7](https://github.com/aws/language-servers/commit/b6226e758d52d8db85c844cab82e6b604566e2ef)) + ## [0.0.113](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.112...lsp-codewhisperer/v0.0.113) (2026-04-29) diff --git a/server/aws-lsp-codewhisperer/package.json b/server/aws-lsp-codewhisperer/package.json index 325d183d16..fcb88fd504 100644 --- a/server/aws-lsp-codewhisperer/package.json +++ b/server/aws-lsp-codewhisperer/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-codewhisperer", - "version": "0.0.113", + "version": "0.0.114", "description": "CodeWhisperer Language Server", "main": "out/index.js", "repository": { From a6d27b6c9b61407c921400c558e42538940a1c4b Mon Sep 17 00:00:00 2001 From: chungjac Date: Tue, 5 May 2026 19:33:48 -0700 Subject: [PATCH 9/9] chore: bump agentic version: 1.66.0 (#2717) Co-authored-by: aws-toolkit-automation <> --- app/aws-lsp-codewhisperer-runtimes/src/version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/aws-lsp-codewhisperer-runtimes/src/version.json b/app/aws-lsp-codewhisperer-runtimes/src/version.json index 95fcdb7741..388c9a8a55 100644 --- a/app/aws-lsp-codewhisperer-runtimes/src/version.json +++ b/app/aws-lsp-codewhisperer-runtimes/src/version.json @@ -1,3 +1,3 @@ { - "agenticChat": "1.65.0" + "agenticChat": "1.66.0" }