From 2080a0e597a604e098daaa2b7fe622f0131c04af Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Tue, 5 Aug 2025 15:45:50 -0700 Subject: [PATCH 01/10] Updating the model of listAvailableModels (#2064) --- .../src/client/token/bearer-token-service.json | 4 ++++ .../src/client/token/codewhispererbearertokenclient.d.ts | 5 +++++ .../src/shared/codeWhispererService.ts | 7 +++++++ 3 files changed, 16 insertions(+) diff --git a/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json b/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json index 2120382d25..957627d234 100644 --- a/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json +++ b/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json @@ -3573,6 +3573,10 @@ "shape": "Models", "documentation": "

List of available models

" }, + "defaultModel": { + "shape": "Model", + "documentation": "

Default model set by the client

" + }, "nextToken": { "shape": "Base64EncodedPaginationToken", "documentation": "

Token for retrieving the next page of results

" diff --git a/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts b/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts index 2189944d4f..ac0739bc05 100644 --- a/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts +++ b/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts @@ -3,6 +3,7 @@ * THIS FILE IS AUTOGENERATED BY 'generateServiceClient.ts'. * DO NOT EDIT BY HAND. */ + import {Request} from 'aws-sdk/lib/request'; import {Response} from 'aws-sdk/lib/response'; import {AWSError} from 'aws-sdk/lib/error'; @@ -1118,6 +1119,10 @@ declare namespace CodeWhispererBearerTokenClient { * List of available models */ models: Models; + /** + * Default model set by the client + */ + defaultModel?: Model; /** * Token for retrieving the next page of results */ diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts index b7ef57ee66..d1db107014 100644 --- a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts @@ -459,6 +459,13 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { return this.client.listAvailableProfiles(request).promise() } + /** + * @description Get list of available models + */ + async listAvailableModels(request: CodeWhispererTokenClient.ListAvailableModelsRequest) { + return this.client.listAvailableModels(request).promise() + } + /** * @description send telemetry event to code whisperer data warehouse */ From c3751f251c4a4d8395c120283105a97458e7b2d7 Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:58:42 -0700 Subject: [PATCH 02/10] fix(amazonq): fix flickering issue for model selection dropdown and agenticCoding toggle (#2065) * fix(amazonq): fix flickering issue for modelId and agenticCoding * fix(amazonq): Fixing flaky tests --- chat-client/src/client/mynahUi.test.ts | 4 +++- chat-client/src/client/mynahUi.ts | 23 +++++++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/chat-client/src/client/mynahUi.test.ts b/chat-client/src/client/mynahUi.test.ts index 31e64f0e76..727805ebbc 100644 --- a/chat-client/src/client/mynahUi.test.ts +++ b/chat-client/src/client/mynahUi.test.ts @@ -260,7 +260,8 @@ describe('MynahUI', () => { sinon.assert.calledThrice(updateStoreSpy) }) - it('should create a new tab if current tab is loading', () => { + it('should create a new tab if current tab is loading', function (done) { + this.timeout(8000) // clear create tab stub since set up process calls it twice createTabStub.resetHistory() getAllTabsStub.returns({ 'tab-1': { store: { loadingChat: true } } }) @@ -274,6 +275,7 @@ describe('MynahUI', () => { sinon.assert.calledOnceWithExactly(createTabStub, false) sinon.assert.calledThrice(updateStoreSpy) + done() }) it('should not create a new tab if one exists already', () => { diff --git a/chat-client/src/client/mynahUi.ts b/chat-client/src/client/mynahUi.ts index 3b42249331..525a9382e1 100644 --- a/chat-client/src/client/mynahUi.ts +++ b/chat-client/src/client/mynahUi.ts @@ -151,11 +151,20 @@ export const handlePromptInputChange = (mynahUi: MynahUI, tabId: string, options } } + const updatedPromptInputOptions = promptInputOptions?.map(option => { + option.value = optionsValues[option.id] + return option + }) + mynahUi.updateStore(tabId, { - promptInputOptions: promptInputOptions?.map(option => { - option.value = optionsValues[option.id] - return option - }), + promptInputOptions: updatedPromptInputOptions, + }) + + // Store the updated values in tab defaults for new tabs + mynahUi.updateTabDefaults({ + store: { + promptInputOptions: updatedPromptInputOptions, + }, }) } @@ -414,6 +423,12 @@ export const createMynahUi = ( } const tabStore = mynahUi.getTabData(tabId).getStore() + const storedPromptInputOptions = mynahUi.getTabDefaults().store?.promptInputOptions + + // Retrieve stored model selection and pair programming mode from defaults + if (storedPromptInputOptions) { + defaultTabConfig.promptInputOptions = storedPromptInputOptions + } // Tabs can be opened through different methods, including server-initiated 'openTab' requests. // The 'openTab' request is specifically used for loading historical chat sessions with pre-existing messages. From 837798d576bb57a8dab2d44438c17d5143779575 Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Fri, 8 Aug 2025 09:49:49 -0700 Subject: [PATCH 03/10] feat(amazonq): Fetching models from backend and adding cache implementation. (#2075) * fix: removing and refactoring legacy code before implementing model selection * feat(amazonq): adding cache implementation and fetching models from listAvailableModels api * feat(amazonq): adding selected model in error case * feat(amazonq): adding test cases * fix: addressing comments * fix: fixing test cases and adding modelName to models * fix: minor edits * fix: minor edits * fix: minor modifications in logs * fix: adding default model if api throws any errors * fix: refactoring code --- chat-client/src/client/chat.ts | 15 - .../src/client/tabs/tabFactory.test.ts | 7 +- chat-client/src/client/tabs/tabFactory.ts | 7 +- .../src/client/texts/modelSelection.test.ts | 54 +-- .../src/client/texts/modelSelection.ts | 36 +- .../sigv4/codewhisperersigv4client.d.ts | 2 +- .../client/token/bearer-token-service.json | 11 + .../token/codewhispererbearertokenclient.d.ts | 5 + .../agenticChat/agenticChatController.test.ts | 316 ++++++++++++------ .../agenticChat/agenticChatController.ts | 150 ++++++--- .../constants/modelSelection.test.ts | 67 +--- .../agenticChat/constants/modelSelection.ts | 16 +- .../agenticChat/tools/chatDb/chatDb.ts | 34 +- .../agenticChat/tools/chatDb/util.ts | 12 + .../utils/agenticChatControllerHelper.ts | 16 - .../src/shared/constants.ts | 2 + 16 files changed, 412 insertions(+), 338 deletions(-) delete mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/agenticChatControllerHelper.ts diff --git a/chat-client/src/client/chat.ts b/chat-client/src/client/chat.ts index d090a33f70..58519d96ef 100644 --- a/chat-client/src/client/chat.ts +++ b/chat-client/src/client/chat.ts @@ -116,7 +116,6 @@ import { InboundChatApi, createMynahUi } from './mynahUi' import { TabFactory } from './tabs/tabFactory' import { ChatClientAdapter } from '../contracts/chatClientAdapter' import { toMynahContextCommand, toMynahIcon } from './utils' -import { modelSelectionForRegion } from './texts/modelSelection' const getDefaultTabConfig = (agenticMode?: boolean) => { return { @@ -264,20 +263,6 @@ export const createChat = ( return option }), }) - } else if (message.params.region) { - // TODO: This can be removed after all clients support aws/chat/listAvailableModels - // get all tabs and update region - const allExistingTabs: MynahUITabStoreModel = mynahUi.getAllTabs() - for (const tabId in allExistingTabs) { - const options = mynahUi.getTabData(tabId).getStore()?.promptInputOptions - mynahUi.updateStore(tabId, { - promptInputOptions: options?.map(option => - option.id === 'model-selection' - ? modelSelectionForRegion[message.params.region] - : option - ), - }) - } } else { tabFactory.setInfoMessages((message.params as ChatOptionsUpdateParams).chatNotifications) } diff --git a/chat-client/src/client/tabs/tabFactory.test.ts b/chat-client/src/client/tabs/tabFactory.test.ts index c398c709eb..815e81a22e 100644 --- a/chat-client/src/client/tabs/tabFactory.test.ts +++ b/chat-client/src/client/tabs/tabFactory.test.ts @@ -2,7 +2,7 @@ import { ChatHistory } from '../features/history' import { TabFactory } from './tabFactory' import * as assert from 'assert' import { pairProgrammingPromptInput } from '../texts/pairProgramming' -import { modelSelectionForRegion } from '../texts/modelSelection' +import { modelSelection } from '../texts/modelSelection' describe('tabFactory', () => { describe('getDefaultTabData', () => { @@ -92,10 +92,7 @@ describe('tabFactory', () => { const result = tabFactory.createTab(false) - assert.deepStrictEqual(result.promptInputOptions, [ - pairProgrammingPromptInput, - modelSelectionForRegion['us-east-1'], - ]) + assert.deepStrictEqual(result.promptInputOptions, [pairProgrammingPromptInput, modelSelection]) }) it('should not include model selection when only agentic mode is enabled', () => { diff --git a/chat-client/src/client/tabs/tabFactory.ts b/chat-client/src/client/tabs/tabFactory.ts index af1af21e7d..c0ab3ebac9 100644 --- a/chat-client/src/client/tabs/tabFactory.ts +++ b/chat-client/src/client/tabs/tabFactory.ts @@ -11,7 +11,7 @@ import { disclaimerCard } from '../texts/disclaimer' import { ChatMessage } from '@aws/language-server-runtimes-types' import { ChatHistory } from '../features/history' import { pairProgrammingPromptInput, programmerModeCard } from '../texts/pairProgramming' -import { modelSelectionForRegion } from '../texts/modelSelection' +import { modelSelection } from '../texts/modelSelection' export type DefaultTabData = MynahUIDataModel @@ -52,10 +52,7 @@ export class TabFactory { ...this.getDefaultTabData(), ...(disclaimerCardActive ? { promptInputStickyCard: disclaimerCard } : {}), promptInputOptions: this.agenticMode - ? [ - pairProgrammingPromptInput, - ...(this.modelSelectionEnabled ? [modelSelectionForRegion['us-east-1']] : []), - ] + ? [pairProgrammingPromptInput, ...(this.modelSelectionEnabled ? [modelSelection] : [])] : [], cancelButtonWhenLoading: this.agenticMode, // supported for agentic chat only } diff --git a/chat-client/src/client/texts/modelSelection.test.ts b/chat-client/src/client/texts/modelSelection.test.ts index 762eb6f818..abd010436e 100644 --- a/chat-client/src/client/texts/modelSelection.test.ts +++ b/chat-client/src/client/texts/modelSelection.test.ts @@ -1,63 +1,11 @@ import * as assert from 'assert' -import { - BedrockModel, - modelSelectionForRegion, - getModelSelectionChatItem, - modelUnavailableBanner, - modelThrottledBanner, -} from './modelSelection' +import { getModelSelectionChatItem, modelUnavailableBanner, modelThrottledBanner } from './modelSelection' import { ChatItemType } from '@aws/mynah-ui' /** * Tests for modelSelection functionality - * - * Note: Some tests are for deprecated code (marked with 'legacy') that is maintained - * for backward compatibility with older clients. These should be removed once - * all clients have been updated to use the new API (aws/chat/listAvailableModels). */ describe('modelSelection', () => { - describe('BedrockModel enum (legacy)', () => { - it('should have the correct model IDs', () => { - assert.strictEqual(BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0, 'CLAUDE_3_7_SONNET_20250219_V1_0') - assert.strictEqual(BedrockModel.CLAUDE_SONNET_4_20250514_V1_0, 'CLAUDE_SONNET_4_20250514_V1_0') - }) - }) - - describe('modelSelectionForRegion (legacy)', () => { - it('should provide all models for us-east-1 region', () => { - const usEast1ModelSelection = modelSelectionForRegion['us-east-1'] - assert.ok(usEast1ModelSelection, 'usEast1ModelSelection should exist') - assert.ok(usEast1ModelSelection.type === 'select', 'usEast1ModelSelection should be type select') - assert.ok(Array.isArray(usEast1ModelSelection.options), 'options should be an array') - assert.strictEqual(usEast1ModelSelection.options.length, 2, 'should have 2 options') - - const modelIds = usEast1ModelSelection.options.map(option => option.value) - assert.ok(modelIds.includes(BedrockModel.CLAUDE_SONNET_4_20250514_V1_0), 'should include Claude Sonnet 4') - assert.ok( - modelIds.includes(BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0), - 'should include Claude Sonnet 3.7' - ) - }) - - it('should provide limited models for eu-central-1 region', () => { - const euCentral1ModelSelection = modelSelectionForRegion['eu-central-1'] - assert.ok(euCentral1ModelSelection, 'euCentral1ModelSelection should exist') - assert.ok(euCentral1ModelSelection.type === 'select', 'euCentral1ModelSelection should be type select') - assert.ok(Array.isArray(euCentral1ModelSelection.options), 'options should be an array') - assert.strictEqual(euCentral1ModelSelection.options.length, 1, 'should have 1 option') - - const modelIds = euCentral1ModelSelection.options.map(option => option.value) - assert.ok( - !modelIds.includes(BedrockModel.CLAUDE_SONNET_4_20250514_V1_0), - 'should not include Claude Sonnet 4' - ) - assert.ok( - modelIds.includes(BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0), - 'should include Claude Sonnet 3.7' - ) - }) - }) - describe('getModelSelectionChatItem', () => { it('should return a chat item with the correct model name', () => { const modelName = 'Claude Sonnet 4' diff --git a/chat-client/src/client/texts/modelSelection.ts b/chat-client/src/client/texts/modelSelection.ts index 28fe969bc9..4dcaad95ba 100644 --- a/chat-client/src/client/texts/modelSelection.ts +++ b/chat-client/src/client/texts/modelSelection.ts @@ -1,48 +1,14 @@ import { ChatItem, ChatItemFormItem, ChatItemType } from '@aws/mynah-ui' -/** - * @deprecated use aws/chat/listAvailableModels server request instead - */ -export enum BedrockModel { - CLAUDE_SONNET_4_20250514_V1_0 = 'CLAUDE_SONNET_4_20250514_V1_0', - CLAUDE_3_7_SONNET_20250219_V1_0 = 'CLAUDE_3_7_SONNET_20250219_V1_0', -} - -type ModelDetails = { - label: string -} - -const modelRecord: Record = { - [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'Claude Sonnet 3.7' }, - [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { label: 'Claude Sonnet 4' }, -} - -const modelOptions = Object.entries(modelRecord).map(([value, { label }]) => ({ - value, - label, -})) - -const modelSelection: ChatItemFormItem = { +export const modelSelection: ChatItemFormItem = { type: 'select', id: 'model-selection', - options: modelOptions, mandatory: true, hideMandatoryIcon: true, border: false, autoWidth: true, } -/** - * @deprecated use aws/chat/listAvailableModels server request instead - */ -export const modelSelectionForRegion: Record = { - 'us-east-1': modelSelection, - 'eu-central-1': { - ...modelSelection, - options: modelOptions.filter(option => option.value !== BedrockModel.CLAUDE_SONNET_4_20250514_V1_0), - }, -} - export const getModelSelectionChatItem = (modelName: string): ChatItem => ({ type: ChatItemType.DIRECTIVE, contentHorizontalAlignment: 'center', diff --git a/server/aws-lsp-codewhisperer/src/client/sigv4/codewhisperersigv4client.d.ts b/server/aws-lsp-codewhisperer/src/client/sigv4/codewhisperersigv4client.d.ts index 308ead42ee..6cb67d3ef8 100644 --- a/server/aws-lsp-codewhisperer/src/client/sigv4/codewhisperersigv4client.d.ts +++ b/server/aws-lsp-codewhisperer/src/client/sigv4/codewhisperersigv4client.d.ts @@ -3,7 +3,7 @@ * THIS FILE IS AUTOGENERATED BY 'generateServiceClient.ts'. * DO NOT EDIT BY HAND. */ - + import {Request} from 'aws-sdk/lib/request'; import {Response} from 'aws-sdk/lib/response'; import {AWSError} from 'aws-sdk/lib/error'; diff --git a/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json b/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json index 957627d234..88e6e8d177 100644 --- a/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json +++ b/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json @@ -3901,6 +3901,10 @@ "shape": "ModelId", "documentation": "

Unique identifier for the model

" }, + "modelName": { + "shape": "ModelName", + "documentation": "

User-facing display name

" + }, "description": { "shape": "Description", "documentation": "

Description of the model

" @@ -3918,6 +3922,13 @@ "min": 1, "pattern": "[a-zA-Z0-9_:.-]+" }, + "ModelName": { + "type": "string", + "documentation": "

Identifier for the model Name

", + "max": 1024, + "min": 1, + "pattern": "[a-zA-Z0-9-_.]+" + }, "ModelMetadata": { "type": "structure", "members": { diff --git a/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts b/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts index ac0739bc05..86798cce75 100644 --- a/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts +++ b/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts @@ -1228,6 +1228,10 @@ declare namespace CodeWhispererBearerTokenClient { * Unique identifier for the model */ modelId: ModelId; + /** + * User-facing display name + */ + modelName?: ModelName; /** * Description of the model */ @@ -1238,6 +1242,7 @@ declare namespace CodeWhispererBearerTokenClient { modelMetadata?: ModelMetadata; } export type ModelId = string; + export type ModelName = string; export interface ModelMetadata { /** * Maximum number of input tokens the model can process 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 3ee23089b2..ede7ae1fad 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 @@ -32,6 +32,7 @@ import { InlineChatResult, CancellationTokenSource, ContextCommand, + ChatUpdateParams, } from '@aws/language-server-runtimes/server-interface' import { TestFeatures } from '@aws/language-server-runtimes/testing' import * as assert from 'assert' @@ -56,7 +57,7 @@ import { LocalProjectContextController } from '../../shared/localProjectContextC import { CancellationError } from '@aws/lsp-core' import { ToolApprovalException } from './tools/toolShared' import * as constants from './constants/constants' -import { GENERATE_ASSISTANT_RESPONSE_INPUT_LIMIT, GENERIC_ERROR_MS } from './constants/constants' +import { DEFAULT_MODEL_ID, GENERATE_ASSISTANT_RESPONSE_INPUT_LIMIT, GENERIC_ERROR_MS } from './constants/constants' import { MISSING_BEARER_TOKEN_ERROR } from '../../shared/constants' import { AmazonQError, @@ -367,17 +368,6 @@ describe('AgenticChatController', () => { sinon.assert.calledWithExactly(activeTabSpy.set, mockTabId) }) - it('onTabAdd updates model ID in chat options and session', () => { - const modelId = 'test-model-id' - sinon.stub(ChatDatabase.prototype, 'getModelId').returns(modelId) - chatController.onTabAdd({ tabId: mockTabId }) - - sinon.assert.calledWithExactly(testFeatures.chat.chatOptionsUpdate, { modelId, tabId: mockTabId }) - - const session = chatSessionManagementService.getSession(mockTabId).data - assert.strictEqual(session!.modelId, modelId) - }) - it('onTabChange sets active tab id in telemetryController and emits metrics', () => { chatController.onTabChange({ tabId: mockTabId }) @@ -2993,133 +2983,251 @@ ${' '.repeat(8)}} }) describe('onListAvailableModels', () => { - let tokenServiceManagerStub: sinon.SinonStub + let isCachedModelsValidStub: sinon.SinonStub + let getCachedModelsStub: sinon.SinonStub + let setCachedModelsStub: sinon.SinonStub + let getConnectionTypeStub: sinon.SinonStub + let getActiveProfileArnStub: sinon.SinonStub + let getCodewhispererServiceStub: sinon.SinonStub + let listAvailableModelsStub: sinon.SinonStub beforeEach(() => { - // Create a session with a model ID + // Create a session chatController.onTabAdd({ tabId: mockTabId }) - const session = chatSessionManagementService.getSession(mockTabId).data! - session.modelId = 'CLAUDE_3_7_SONNET_20250219_V1_0' - // Stub the getRegion method - tokenServiceManagerStub = sinon.stub(AmazonQTokenServiceManager.prototype, 'getRegion') + // Stub ChatDatabase methods + isCachedModelsValidStub = sinon.stub(ChatDatabase.prototype, 'isCachedModelsValid') + getCachedModelsStub = sinon.stub(ChatDatabase.prototype, 'getCachedModels') + setCachedModelsStub = sinon.stub(ChatDatabase.prototype, 'setCachedModels') + + // Stub AmazonQTokenServiceManager methods + getConnectionTypeStub = sinon.stub(AmazonQTokenServiceManager.prototype, 'getConnectionType') + getActiveProfileArnStub = sinon.stub(AmazonQTokenServiceManager.prototype, 'getActiveProfileArn') + getCodewhispererServiceStub = sinon.stub(AmazonQTokenServiceManager.prototype, 'getCodewhispererService') + + // Mock listAvailableModels method + listAvailableModelsStub = sinon.stub() + getCodewhispererServiceStub.returns({ + listAvailableModels: listAvailableModelsStub, + }) }) afterEach(() => { - tokenServiceManagerStub.restore() - }) + isCachedModelsValidStub.restore() + getCachedModelsStub.restore() + setCachedModelsStub.restore() + getConnectionTypeStub.restore() + getActiveProfileArnStub.restore() + getCodewhispererServiceStub.restore() + }) + + describe('ListAvailableModels Cache scenarios', () => { + it('should return cached models when cache is valid', async () => { + // Setup valid cache + isCachedModelsValidStub.returns(true) + const cachedData = { + models: [ + { id: 'model1', name: 'Model 1' }, + { id: 'model2', name: 'Model 2' }, + ], + defaultModelId: 'model1', + timestamp: Date.now(), + } + getCachedModelsStub.returns(cachedData) - it('should return all available models for us-east-1 region', async () => { - // Set up the region to be us-east-1 - tokenServiceManagerStub.returns('us-east-1') + const session = chatSessionManagementService.getSession(mockTabId).data! + session.modelId = 'model1' - // Call the method - const params = { tabId: mockTabId } - const result = await chatController.onListAvailableModels(params) + const result = await chatController.onListAvailableModels({ tabId: mockTabId }) - // Verify the result - assert.strictEqual(result.tabId, mockTabId) - assert.strictEqual(result.models.length, 2) - assert.strictEqual(result.selectedModelId, 'CLAUDE_SONNET_4_20250514_V1_0') + // Verify cached data is used + assert.strictEqual(result.tabId, mockTabId) + assert.deepStrictEqual(result.models, cachedData.models) + assert.strictEqual(result.selectedModelId, 'model1') - // Check that the models include both Claude versions - const modelIds = result.models.map(model => model.id) - assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0')) - assert.ok(modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0')) - }) + // Verify API was not called + sinon.assert.notCalled(listAvailableModelsStub) + sinon.assert.notCalled(setCachedModelsStub) + }) - it('should return limited models for eu-central-1 region', async () => { - // Set up the region to be eu-central-1 - tokenServiceManagerStub.returns('eu-central-1') + it('should return cached models when cache is valid but has empty models array', async () => { + // Setup cache with empty models + isCachedModelsValidStub.returns(true) + const cachedData = { + models: [], + defaultModelId: undefined, + timestamp: Date.now(), + } + getCachedModelsStub.returns(cachedData) - // Call the method - const params = { tabId: mockTabId } - const result = await chatController.onListAvailableModels(params) + // Should fall back to API call since models array is empty + getConnectionTypeStub.returns('builderId') + getActiveProfileArnStub.returns('test-arn') + listAvailableModelsStub.resolves({ + models: { + model1: { modelId: 'model1' }, + model2: { modelId: 'model2' }, + }, + defaultModel: { modelId: 'model1' }, + }) - // Verify the result - assert.strictEqual(result.tabId, mockTabId) - assert.strictEqual(result.models.length, 1) - assert.strictEqual(result.selectedModelId, 'CLAUDE_3_7_SONNET_20250219_V1_0') + await chatController.onListAvailableModels({ tabId: mockTabId }) - // Check that the models only include Claude 3.7 - const modelIds = result.models.map(model => model.id) - assert.ok(!modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0')) - assert.ok(modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0')) - }) + // Verify API was called due to empty cached models + sinon.assert.calledOnce(listAvailableModelsStub) + sinon.assert.calledOnce(setCachedModelsStub) + }) - it('should return all models when region is unknown', async () => { - // Set up the region to be unknown - tokenServiceManagerStub.returns('unknown-region') + it('should return cached models when cache is valid but cachedData is null', async () => { + // Setup cache as valid but returns null + isCachedModelsValidStub.returns(true) + getCachedModelsStub.returns(null) - // Call the method - const params = { tabId: mockTabId } - const result = await chatController.onListAvailableModels(params) + // Should fall back to API call + getConnectionTypeStub.returns('builderId') + getActiveProfileArnStub.returns('test-arn') + listAvailableModelsStub.resolves({ + models: { + model1: { modelId: 'model1' }, + }, + defaultModel: { modelId: 'model1' }, + }) - // Verify the result - assert.strictEqual(result.tabId, mockTabId) - assert.strictEqual(result.models.length, 2) - assert.strictEqual(result.selectedModelId, 'CLAUDE_3_7_SONNET_20250219_V1_0') + await chatController.onListAvailableModels({ tabId: mockTabId }) + + // Verify API was called + sinon.assert.calledOnce(listAvailableModelsStub) + }) }) - it('should return undefined for selectedModelId when no session data exists', async () => { - // Set up the session to return no session (failure case) - const getSessionStub = sinon.stub(chatSessionManagementService, 'getSession') - getSessionStub.returns({ - data: undefined, - success: false, - error: 'error', + describe('ListAvailableModels API call scenarios', () => { + beforeEach(() => { + // Setup invalid cache to force API call + isCachedModelsValidStub.returns(false) }) - // Call the method - const params = { tabId: 'non-existent-tab' } - const result = await chatController.onListAvailableModels(params) + it('should fetch models from API when cache is invalid', async () => { + getConnectionTypeStub.returns('builderId') + getActiveProfileArnStub.returns('test-profile-arn') - // Verify the result - assert.strictEqual(result.tabId, 'non-existent-tab') - assert.strictEqual(result.models.length, 2) - assert.strictEqual(result.selectedModelId, undefined) + const mockApiResponse = { + models: { + 'claude-3-sonnet': { modelId: 'claude-3-sonnet' }, + 'claude-4-sonnet': { modelId: 'claude-4-sonnet' }, + }, + defaultModel: { modelId: 'claude-3-sonnet' }, + } + listAvailableModelsStub.resolves(mockApiResponse) - getSessionStub.restore() - }) + const result = await chatController.onListAvailableModels({ tabId: mockTabId }) + + // Verify API call was made with correct parameters + sinon.assert.calledOnceWithExactly(listAvailableModelsStub, { + origin: 'IDE', + profileArn: 'test-profile-arn', + }) + + // Verify result structure + assert.strictEqual(result.tabId, mockTabId) + assert.strictEqual(result.models.length, 2) + assert.deepStrictEqual(result.models, [ + { id: 'claude-3-sonnet', name: 'claude-3-sonnet' }, + { id: 'claude-4-sonnet', name: 'claude-4-sonnet' }, + ]) + + // Verify cache was updated + sinon.assert.calledOnceWithExactly(setCachedModelsStub, result.models, 'claude-3-sonnet') + }) + + it('should fall back to hardcoded models when API call fails', async () => { + getConnectionTypeStub.returns('builderId') + listAvailableModelsStub.rejects(new Error('API Error')) - it('should fallback to latest available model when saved model is not available in current region', async () => { - // Set up the region to be eu-central-1 (which only has Claude 3.7) - tokenServiceManagerStub.returns('eu-central-1') + const result = await chatController.onListAvailableModels({ tabId: mockTabId }) - // Mock database to return Claude Sonnet 4 (not available in eu-central-1) - const getModelIdStub = sinon.stub(ChatDatabase.prototype, 'getModelId') - getModelIdStub.returns('CLAUDE_SONNET_4_20250514_V1_0') + // Verify fallback to FALLBACK_MODEL_OPTIONS + assert.strictEqual(result.tabId, mockTabId) + assert.strictEqual(result.models.length, 2) // FALLBACK_MODEL_OPTIONS length - // Call the method - const params = { tabId: mockTabId } - const result = await chatController.onListAvailableModels(params) + // Verify cache was not updated due to error + sinon.assert.notCalled(setCachedModelsStub) + }) - // Verify the result falls back to available model - assert.strictEqual(result.tabId, mockTabId) - assert.strictEqual(result.models.length, 1) - assert.strictEqual(result.selectedModelId, 'CLAUDE_3_7_SONNET_20250219_V1_0') + it('should handle API response with no defaultModel', async () => { + getConnectionTypeStub.returns('builderId') - getModelIdStub.restore() + const mockApiResponse = { + models: { + model1: { modelId: 'model1' }, + }, + defaultModel: undefined, // No default model + } + listAvailableModelsStub.resolves(mockApiResponse) + + const result = await chatController.onListAvailableModels({ tabId: mockTabId }) + + // Verify cache was updated with undefined defaultModelId + sinon.assert.calledOnceWithExactly(setCachedModelsStub, result.models, undefined) + }) }) - it('should use saved model when it is available in current region', async () => { - // Set up the region to be us-east-1 (which has both models) - tokenServiceManagerStub.returns('us-east-1') + describe('Session and model selection scenarios', () => { + beforeEach(() => { + // Setup cache to avoid API calls in these tests + isCachedModelsValidStub.returns(true) + getCachedModelsStub.returns({ + models: [ + { id: 'model1', name: 'Model 1' }, + { id: 'model2', name: 'Model 2' }, + ], + defaultModelId: 'model1', + timestamp: Date.now(), + }) + }) + + it('should return default model when session fails to load', async () => { + const getSessionStub = sinon.stub(chatSessionManagementService, 'getSession') + getSessionStub.returns({ + data: undefined, + success: false, + error: 'Session not found', + }) + + const result = await chatController.onListAvailableModels({ tabId: 'invalid-tab' }) - // Mock database to return Claude 3.7 (available in us-east-1) - const getModelIdStub = sinon.stub(ChatDatabase.prototype, 'getModelId') - getModelIdStub.returns('CLAUDE_3_7_SONNET_20250219_V1_0') + assert.strictEqual(result.tabId, 'invalid-tab') + assert.strictEqual(result.selectedModelId, DEFAULT_MODEL_ID) - // Call the method - const params = { tabId: mockTabId } - const result = await chatController.onListAvailableModels(params) + getSessionStub.restore() + }) + + it('should use defaultModelId from cache when session has no modelId', async () => { + const session = chatSessionManagementService.getSession(mockTabId).data! + session.modelId = undefined - // Verify the result uses the saved model - assert.strictEqual(result.tabId, mockTabId) - assert.strictEqual(result.models.length, 2) - assert.strictEqual(result.selectedModelId, 'CLAUDE_3_7_SONNET_20250219_V1_0') + const result = await chatController.onListAvailableModels({ tabId: mockTabId }) - getModelIdStub.restore() + assert.strictEqual(result.selectedModelId, 'model1') // defaultModelId from cache + // Verify session modelId is updated + assert.strictEqual(session.modelId, 'model1') + }) + + it('should fall back to default model when session has no modelId and no defaultModelId in cache', async () => { + getCachedModelsStub.returns({ + models: [{ id: 'model1', name: 'Model 1' }], + defaultModelId: undefined, // No default model + timestamp: Date.now(), + }) + + const session = chatSessionManagementService.getSession(mockTabId).data! + session.modelId = undefined + + const result = await chatController.onListAvailableModels({ tabId: mockTabId }) + + assert.strictEqual(result.selectedModelId, 'claude-4-sonnet') // MODEL_RECORD[DEFAULT_MODEL_ID].label + // Verify session modelId is updated + assert.strictEqual(session.modelId, 'claude-4-sonnet') + }) }) }) 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 34d153c6e0..af2a154a70 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -24,7 +24,6 @@ import { GREP_SEARCH, FILE_SEARCH, EXECUTE_BASH, - CODE_REVIEW, BUTTON_RUN_SHELL_COMMAND, BUTTON_REJECT_SHELL_COMMAND, BUTTON_REJECT_MCP_TOOL, @@ -67,8 +66,6 @@ import { ListAvailableModelsResult, OpenFileDialogParams, OpenFileDialogResult, -} from '@aws/language-server-runtimes/protocol' -import { ApplyWorkspaceEditParams, ErrorCodes, FeedbackParams, @@ -83,6 +80,7 @@ import { TabBarActionParams, CreatePromptParams, FileClickParams, + Model, } from '@aws/language-server-runtimes/protocol' import { CancellationToken, @@ -222,14 +220,14 @@ import { Message as DbMessage, messageToStreamingMessage, } from './tools/chatDb/util' -import { MODEL_OPTIONS, MODEL_OPTIONS_FOR_REGION } from './constants/modelSelection' +import { FALLBACK_MODEL_OPTIONS, MODEL_RECORD } from './constants/modelSelection' import { DEFAULT_IMAGE_VERIFICATION_OPTIONS, verifyServerImage } from '../../shared/imageVerification' import { sanitize } from '@aws/lsp-core/out/util/path' -import { getLatestAvailableModel } from './utils/agenticChatControllerHelper' import { ActiveUserTracker } from '../../shared/activeUserTracker' import { UserContext } from '../../client/token/codewhispererbearertokenclient' import { CodeWhispererServiceToken } from '../../shared/codeWhispererService' import { DisplayFindings } from './tools/qCodeAnalysis/displayFindings' +import { IDE } from '../../shared/constants' type ChatHandlers = Omit< LspHandlers, @@ -680,25 +678,122 @@ export class AgenticChatController implements ChatHandlers { return this.#mcpEventHandler.onMcpServerClick(params) } + /** + * Fetches available models either from cache or API + * If cache is valid (less than 5 minutes old), returns cached models + * If cache is invalid or empty, makes an API call and stores results in cache + * If the API throws errors (e.g., throttling), falls back to default models + */ + async #fetchModelsWithCache(): Promise<{ models: Model[]; defaultModelId?: string; errorFromAPI: boolean }> { + let models: Model[] = [] + let defaultModelId: string | undefined + let errorFromAPI = false + + // Check if cache is valid (less than 5 minutes old) + if (this.#chatHistoryDb.isCachedModelsValid()) { + const cachedData = this.#chatHistoryDb.getCachedModels() + if (cachedData && cachedData.models && cachedData.models.length > 0) { + this.#log('Using cached models, last updated at:', new Date(cachedData.timestamp).toISOString()) + return { + models: cachedData.models, + defaultModelId: cachedData.defaultModelId, + errorFromAPI: false, + } + } + } + + // If cache is invalid or empty, make an API call + this.#log('Cache miss or expired, fetching models from API') + try { + const client = AmazonQTokenServiceManager.getInstance().getCodewhispererService() + const responseResult = await client.listAvailableModels({ + origin: IDE, + profileArn: AmazonQTokenServiceManager.getInstance().getConnectionType() + ? AmazonQTokenServiceManager.getInstance().getActiveProfileArn() + : undefined, + }) + + // Wait for the response to be completed before proceeding + this.#log('Model Response', responseResult.toString()) + models = Object.values(responseResult.models).map(({ modelId, modelName }) => ({ + id: modelId, + name: modelName ?? modelId, + })) + defaultModelId = responseResult.defaultModel?.modelId + + // Cache the models with defaultModelId + this.#chatHistoryDb.setCachedModels(models, defaultModelId) + } catch (err) { + // In case of API throttling or other errors, fall back to hardcoded models + this.#log('Error fetching models from API, using fallback models:', fmtError(err)) + errorFromAPI = true + models = FALLBACK_MODEL_OPTIONS + } + + return { + models, + defaultModelId, + errorFromAPI, + } + } + + /** + * This function handles the model selection process for the chat interface. + * It first attempts to retrieve models from cache or API, then determines the appropriate model to select + * based on the following priority: + * 1. When errors occur or session is invalid: Use the default model as a fallback + * 2. When user has previously selected a model: Use that model (or its mapped version if the model ID has changed) + * 3. When there's a default model from the API: Use the server-recommended default model + * 4. Last resort: Use the newest model defined in modelSelection constants + * + * This ensures users maintain consistent model selection across sessions while also handling + * API failures and model ID migrations gracefully. + */ async onListAvailableModels(params: ListAvailableModelsParams): Promise { - const region = AmazonQTokenServiceManager.getInstance().getRegion() - const models = region && MODEL_OPTIONS_FOR_REGION[region] ? MODEL_OPTIONS_FOR_REGION[region] : MODEL_OPTIONS + // Get models from cache or API + const { models, defaultModelId, errorFromAPI } = await this.#fetchModelsWithCache() + + // Get the first fallback model option as default + const defaultModelOption = FALLBACK_MODEL_OPTIONS[1] + const DEFAULT_MODEL_ID = defaultModelOption?.id || Object.keys(MODEL_RECORD)[1] const sessionResult = this.#chatSessionManagementService.getSession(params.tabId) const { data: session, success } = sessionResult - if (!success) { + + // Handle error cases by returning default model + if (!success || errorFromAPI) { return { tabId: params.tabId, models: models, + selectedModelId: DEFAULT_MODEL_ID, + } + } + + // Determine which model ID to select based on priority. + let selectedModelId: string + let modelId = this.#chatHistoryDb.getModelId() + + if (modelId) { + // Case 1: User has previously selected a model - use that model or its mapped version + // Check if modelId is a key in MODEL_RECORD + if (modelId in MODEL_RECORD) { + // If it's a valid model key, use its mapped label + selectedModelId = MODEL_RECORD[modelId as keyof typeof MODEL_RECORD]?.label || modelId + } else { + // Otherwise use as is (might be a direct model ID from the API) + selectedModelId = modelId } + } else if (defaultModelId) { + // Case 2: No user selection, but API provided a default model - use server recommendation + selectedModelId = defaultModelId + } else { + // Case 3: Last resort - use default model's label + selectedModelId = MODEL_RECORD[DEFAULT_MODEL_ID as keyof typeof MODEL_RECORD]?.label || DEFAULT_MODEL_ID } - const savedModelId = this.#chatHistoryDb.getModelId() - const selectedModelId = - savedModelId && models.some(model => model.id === savedModelId) - ? savedModelId - : getLatestAvailableModel(region).id + // Store the selected model in the session session.modelId = selectedModelId + return { tabId: params.tabId, models: models, @@ -3490,25 +3585,6 @@ export class AgenticChatController implements ChatHandlers { onSourceLinkClick() {} - /** - * @deprecated use aws/chat/listAvailableModels server request instead - */ - #legacySetModelId(tabId: string, session: ChatSessionService) { - // Since model selection is mandatory, the only time modelId is not set is when the chat history is empty. - // In that case, we use the default modelId. - let modelId = this.#chatHistoryDb.getModelId() ?? DEFAULT_MODEL_ID - - const region = AmazonQTokenServiceManager.getInstance().getRegion() - if (region === 'eu-central-1') { - // Only 3.7 Sonnet is available in eu-central-1 for now - modelId = 'CLAUDE_3_7_SONNET_20250219_V1_0' - // @ts-ignore - this.#features.chat.chatOptionsUpdate({ region }) - } - this.#features.chat.chatOptionsUpdate({ modelId: modelId, tabId: tabId }) - session.modelId = modelId - } - onTabAdd(params: TabAddParams) { this.#telemetryController.activeTabId = params.tabId @@ -3521,11 +3597,14 @@ export class AgenticChatController implements ChatHandlers { if (!success) { return new ResponseError(ErrorCodes.InternalError, sessionResult.error) } - this.#legacySetModelId(params.tabId, session) // Get the saved pair programming mode from the database or default to true if not found const savedPairProgrammingMode = this.#chatHistoryDb.getPairProgrammingMode() session.pairProgrammingMode = savedPairProgrammingMode !== undefined ? savedPairProgrammingMode : true + if (session) { + // Set the logging object on the session + session.setLogging(this.#features.logging) + } // Update the client with the initial pair programming mode this.#features.chat.chatOptionsUpdate({ @@ -3533,11 +3612,6 @@ export class AgenticChatController implements ChatHandlers { // Type assertion to support pairProgrammingMode ...(session.pairProgrammingMode !== undefined ? { pairProgrammingMode: session.pairProgrammingMode } : {}), } as ChatUpdateParams) - - if (success && session) { - // Set the logging object on the session - session.setLogging(this.#features.logging) - } this.setPaidTierMode(params.tabId) } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts index 0fc768d88e..dbf694b876 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts @@ -1,19 +1,19 @@ import * as assert from 'assert' -import { MODEL_OPTIONS, MODEL_OPTIONS_FOR_REGION } from './modelSelection' +import { FALLBACK_MODEL_OPTIONS } from './modelSelection' describe('modelSelection', () => { describe('modelOptions', () => { it('should contain the correct model options', () => { - assert.ok(Array.isArray(MODEL_OPTIONS), 'modelOptions should be an array') - assert.strictEqual(MODEL_OPTIONS.length, 2, 'modelOptions should have 2 items') + assert.ok(Array.isArray(FALLBACK_MODEL_OPTIONS), 'modelOptions should be an array') + assert.strictEqual(FALLBACK_MODEL_OPTIONS.length, 2, 'modelOptions should have 2 items') // Check that the array contains the expected models - const modelIds = MODEL_OPTIONS.map(model => model.id) - assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'), 'Should include Claude Sonnet 4') - assert.ok(modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0'), 'Should include Claude Sonnet 3.7') + const modelIds = FALLBACK_MODEL_OPTIONS.map(model => model.id) + assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'), 'Should include claude-4-sonnet') + assert.ok(modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0'), 'Should include claude-3.7-sonnet') // Check that each model has the required properties - MODEL_OPTIONS.forEach(model => { + FALLBACK_MODEL_OPTIONS.forEach(model => { assert.ok('id' in model, 'Model should have id property') assert.ok('name' in model, 'Model should have name property') assert.strictEqual(typeof model.id, 'string', 'Model id should be a string') @@ -21,56 +21,11 @@ describe('modelSelection', () => { }) // Check specific model names - const claudeSonnet4 = MODEL_OPTIONS.find(model => model.id === 'CLAUDE_SONNET_4_20250514_V1_0') - const claudeSonnet37 = MODEL_OPTIONS.find(model => model.id === 'CLAUDE_3_7_SONNET_20250219_V1_0') + const claudeSonnet4 = FALLBACK_MODEL_OPTIONS.find(model => model.id === 'CLAUDE_SONNET_4_20250514_V1_0') + const claudeSonnet37 = FALLBACK_MODEL_OPTIONS.find(model => model.id === 'CLAUDE_3_7_SONNET_20250219_V1_0') - assert.strictEqual(claudeSonnet4?.name, 'Claude Sonnet 4', 'Claude Sonnet 4 should have correct name') - assert.strictEqual(claudeSonnet37?.name, 'Claude Sonnet 3.7', 'Claude Sonnet 3.7 should have correct name') - }) - }) - - describe('modelOptionsForRegion', () => { - it('should provide all models for us-east-1 region', () => { - const usEast1Models = MODEL_OPTIONS_FOR_REGION['us-east-1'] - assert.deepStrictEqual(usEast1Models, MODEL_OPTIONS, 'us-east-1 should have all models') - assert.strictEqual(usEast1Models.length, 2, 'us-east-1 should have 2 models') - - const modelIds = usEast1Models.map(model => model.id) - assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'), 'us-east-1 should include Claude Sonnet 4') - assert.ok( - modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0'), - 'us-east-1 should include Claude Sonnet 3.7' - ) - }) - - it('should provide limited models for eu-central-1 region', () => { - const euCentral1Models = MODEL_OPTIONS_FOR_REGION['eu-central-1'] - assert.ok(Array.isArray(euCentral1Models), 'eu-central-1 models should be an array') - assert.strictEqual(euCentral1Models.length, 1, 'eu-central-1 should have 1 model') - - const modelIds = euCentral1Models.map(model => model.id) - assert.ok( - !modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'), - 'eu-central-1 should not include Claude Sonnet 4' - ) - assert.ok( - modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0'), - 'eu-central-1 should include Claude Sonnet 3.7' - ) - }) - - it('should filter out Claude Sonnet 4 for eu-central-1 region', () => { - const euCentral1Models = MODEL_OPTIONS_FOR_REGION['eu-central-1'] - const claudeSonnet4 = euCentral1Models.find(model => model.id === 'CLAUDE_SONNET_4_20250514_V1_0') - assert.strictEqual(claudeSonnet4, undefined, 'Claude Sonnet 4 should be filtered out for eu-central-1') - }) - - it('should fall back to all models for unknown regions', () => { - // Test with a region that doesn't exist in the modelOptionsForRegion map - const unknownRegionModels = MODEL_OPTIONS_FOR_REGION['unknown-region'] - - // Should be undefined since the region doesn't exist in the map - assert.strictEqual(unknownRegionModels, undefined, 'Unknown region should return undefined') + assert.strictEqual(claudeSonnet4?.name, 'claude-4-sonnet', 'claude-4-sonnet should have correct name') + assert.strictEqual(claudeSonnet37?.name, 'claude-3.7-sonnet', 'claude-3.7-sonnet should have correct name') }) }) }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts index d7ee53cf75..b36b2345f6 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts @@ -1,5 +1,8 @@ import { ListAvailableModelsResult } from '@aws/language-server-runtimes/protocol' +/** + * @deprecated Do not add new models to the enum. + */ export enum BedrockModel { CLAUDE_SONNET_4_20250514_V1_0 = 'CLAUDE_SONNET_4_20250514_V1_0', CLAUDE_3_7_SONNET_20250219_V1_0 = 'CLAUDE_3_7_SONNET_20250219_V1_0', @@ -9,19 +12,14 @@ type ModelDetails = { label: string } -const MODEL_RECORD: Record = { - [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'Claude Sonnet 3.7' }, - [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { label: 'Claude Sonnet 4' }, +export const MODEL_RECORD: Record = { + [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'claude-3.7-sonnet' }, + [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { label: 'claude-4-sonnet' }, } -export const MODEL_OPTIONS: ListAvailableModelsResult['models'] = Object.entries(MODEL_RECORD).map( +export const FALLBACK_MODEL_OPTIONS: ListAvailableModelsResult['models'] = Object.entries(MODEL_RECORD).map( ([value, { label }]) => ({ id: value, name: label, }) ) - -export const MODEL_OPTIONS_FOR_REGION: Record = { - 'us-east-1': MODEL_OPTIONS, - 'eu-central-1': MODEL_OPTIONS.filter(option => option.id !== BedrockModel.CLAUDE_SONNET_4_20250514_V1_0), -} diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.ts index dfcf21308c..4b169c8a69 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.ts @@ -24,11 +24,12 @@ import { getMd5WorkspaceId, MessagesWithCharacterCount, estimateCharacterCountFromImageBlock, + isCachedValid, } from './util' import * as crypto from 'crypto' import * as path from 'path' import { Features } from '@aws/language-server-runtimes/server-interface/server' -import { ContextCommand, ConversationItemGroup } from '@aws/language-server-runtimes/protocol' +import { ContextCommand, ConversationItemGroup, Model } from '@aws/language-server-runtimes/protocol' import { ChatMessage, ToolResultStatus } from '@amzn/codewhisperer-streaming' import { ChatItemType } from '@aws/mynah-ui' import { getUserHomeDir } from '@aws/lsp-core/out/util/path' @@ -1084,6 +1085,37 @@ export class ChatDatabase { this.updateSettings({ modelId: modelId === '' ? undefined : modelId }) } + getCachedModels(): { models: Model[]; defaultModelId?: string; timestamp: number } | undefined { + const settings = this.getSettings() + if (settings?.cachedModels && settings?.modelCacheTimestamp) { + return { + models: settings.cachedModels, + defaultModelId: settings.cachedDefaultModelId, + timestamp: settings.modelCacheTimestamp, + } + } + return undefined + } + + setCachedModels(models: Model[], defaultModelId?: string): void { + const currentTimestamp = Date.now() + // Get existing settings to preserve fields like modelId + const existingSettings = this.getSettings() || { modelId: undefined } + this.updateSettings({ + ...existingSettings, + cachedModels: models, + cachedDefaultModelId: defaultModelId, + modelCacheTimestamp: currentTimestamp, + }) + this.#features.logging.log(`Models cached at timestamp: ${currentTimestamp}`) + } + + isCachedModelsValid(): boolean { + const cachedData = this.getCachedModels() + if (!cachedData) return false + return isCachedValid(cachedData.timestamp) + } + getPairProgrammingMode(): boolean | undefined { const settings = this.getSettings() return settings?.pairProgrammingMode diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts index 8f3f46b9f4..5a2f989f0c 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts @@ -10,6 +10,7 @@ import { ConversationItem, ConversationItemGroup, IconType, + Model, ReferenceTrackerInformation, } from '@aws/language-server-runtimes/server-interface' import { @@ -84,6 +85,9 @@ export type Rules = { export type Settings = { modelId: string | undefined pairProgrammingMode?: boolean + cachedModels?: Model[] + cachedDefaultModelId?: string + modelCacheTimestamp?: number } export type Conversation = { @@ -131,6 +135,14 @@ export type MessagesWithCharacterCount = { currentCount: number } +export function isCachedValid(timestamp: number): boolean { + const currentTime = Date.now() + const cacheAge = currentTime - timestamp + const CACHE_TTL = 5 * 60 * 1000 // 5 minutes in milliseconds + + return cacheAge < CACHE_TTL +} + /** * Converts Message to codewhisperer-streaming ChatMessage */ diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/agenticChatControllerHelper.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/agenticChatControllerHelper.ts deleted file mode 100644 index 55ac7873e2..0000000000 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/agenticChatControllerHelper.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ListAvailableModelsResult } from '@aws/language-server-runtimes/protocol' -import { MODEL_OPTIONS, MODEL_OPTIONS_FOR_REGION } from '../constants/modelSelection' - -/** - * Gets the latest available model for a region, optionally excluding a specific model - * @param region The AWS region - * @param exclude Optional model ID to exclude - * @returns The latest available model - */ -export function getLatestAvailableModel( - region: string | undefined, - exclude?: string -): ListAvailableModelsResult['models'][0] { - const models = region && MODEL_OPTIONS_FOR_REGION[region] ? MODEL_OPTIONS_FOR_REGION[region] : MODEL_OPTIONS - return models.reverse().find(model => model.id !== exclude) ?? models[models.length - 1] -} diff --git a/server/aws-lsp-codewhisperer/src/shared/constants.ts b/server/aws-lsp-codewhisperer/src/shared/constants.ts index cd453a11ba..33f61a079f 100644 --- a/server/aws-lsp-codewhisperer/src/shared/constants.ts +++ b/server/aws-lsp-codewhisperer/src/shared/constants.ts @@ -15,6 +15,8 @@ export const AWS_Q_ENDPOINTS = new Map([ export const AWS_Q_REGION_ENV_VAR = 'AWS_Q_REGION' export const AWS_Q_ENDPOINT_URL_ENV_VAR = 'AWS_Q_ENDPOINT_URL' +export const IDE = 'IDE' + export const Q_CONFIGURATION_SECTION = 'aws.q' export const CODE_WHISPERER_CONFIGURATION_SECTION = 'aws.codeWhisperer' From f3600e362c18ba8f798becfb5d635139525a3896 Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Fri, 8 Aug 2025 14:08:36 -0700 Subject: [PATCH 04/10] fix: Improve model selection fallback logic when user's preferred model is unavailable (#2089) * fix: if user preferred model does not exist, fall back to default model * fix: minor test changes --- .../agenticChat/agenticChatController.test.ts | 2 +- .../agenticChat/agenticChatController.ts | 33 +++++++++++-------- .../agenticChat/constants/modelSelection.ts | 4 +-- 3 files changed, 22 insertions(+), 17 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 ede7ae1fad..a7fc385a5a 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 @@ -3224,7 +3224,7 @@ ${' '.repeat(8)}} const result = await chatController.onListAvailableModels({ tabId: mockTabId }) - assert.strictEqual(result.selectedModelId, 'claude-4-sonnet') // MODEL_RECORD[DEFAULT_MODEL_ID].label + assert.strictEqual(result.selectedModelId, 'claude-4-sonnet') // FALLBACK_MODEL_RECORD[DEFAULT_MODEL_ID].label // Verify session modelId is updated assert.strictEqual(session.modelId, 'claude-4-sonnet') }) 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 af2a154a70..959941ea24 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -220,7 +220,7 @@ import { Message as DbMessage, messageToStreamingMessage, } from './tools/chatDb/util' -import { FALLBACK_MODEL_OPTIONS, MODEL_RECORD } from './constants/modelSelection' +import { FALLBACK_MODEL_OPTIONS, FALLBACK_MODEL_RECORD } from './constants/modelSelection' import { DEFAULT_IMAGE_VERIFICATION_OPTIONS, verifyServerImage } from '../../shared/imageVerification' import { sanitize } from '@aws/lsp-core/out/util/path' import { ActiveUserTracker } from '../../shared/activeUserTracker' @@ -755,7 +755,7 @@ export class AgenticChatController implements ChatHandlers { // Get the first fallback model option as default const defaultModelOption = FALLBACK_MODEL_OPTIONS[1] - const DEFAULT_MODEL_ID = defaultModelOption?.id || Object.keys(MODEL_RECORD)[1] + const DEFAULT_MODEL_ID = defaultModelOption?.id || Object.keys(FALLBACK_MODEL_RECORD)[1] const sessionResult = this.#chatSessionManagementService.getSession(params.tabId) const { data: session, success } = sessionResult @@ -773,22 +773,27 @@ export class AgenticChatController implements ChatHandlers { let selectedModelId: string let modelId = this.#chatHistoryDb.getModelId() + // Helper function to get model label from FALLBACK_MODEL_RECORD + const getModelLabel = (modelKey: string) => + FALLBACK_MODEL_RECORD[modelKey as keyof typeof FALLBACK_MODEL_RECORD]?.label || modelKey + + // Determine selected model ID based on priority if (modelId) { - // Case 1: User has previously selected a model - use that model or its mapped version - // Check if modelId is a key in MODEL_RECORD - if (modelId in MODEL_RECORD) { - // If it's a valid model key, use its mapped label - selectedModelId = MODEL_RECORD[modelId as keyof typeof MODEL_RECORD]?.label || modelId - } else { - // Otherwise use as is (might be a direct model ID from the API) + // Priority 1: Use modelId if it exists in available models from backend + if (models.some(model => model.id === modelId)) { selectedModelId = modelId } - } else if (defaultModelId) { - // Case 2: No user selection, but API provided a default model - use server recommendation - selectedModelId = defaultModelId + // Priority 2: Use mapped version if modelId exists in FALLBACK_MODEL_RECORD + else if (modelId in FALLBACK_MODEL_RECORD) { + selectedModelId = getModelLabel(modelId) + } + // Priority 3: Fall back to default or system default + else { + selectedModelId = defaultModelId || getModelLabel(DEFAULT_MODEL_ID) + } } else { - // Case 3: Last resort - use default model's label - selectedModelId = MODEL_RECORD[DEFAULT_MODEL_ID as keyof typeof MODEL_RECORD]?.label || DEFAULT_MODEL_ID + // No user-selected model - use API default or system default + selectedModelId = defaultModelId || getModelLabel(DEFAULT_MODEL_ID) } // Store the selected model in the session diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts index b36b2345f6..0f3a14ff56 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts @@ -12,12 +12,12 @@ type ModelDetails = { label: string } -export const MODEL_RECORD: Record = { +export const FALLBACK_MODEL_RECORD: Record = { [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'claude-3.7-sonnet' }, [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { label: 'claude-4-sonnet' }, } -export const FALLBACK_MODEL_OPTIONS: ListAvailableModelsResult['models'] = Object.entries(MODEL_RECORD).map( +export const FALLBACK_MODEL_OPTIONS: ListAvailableModelsResult['models'] = Object.entries(FALLBACK_MODEL_RECORD).map( ([value, { label }]) => ({ id: value, name: label, From 05bcc24ab1f5de2266f50c63e583c4cc24888e6e Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Fri, 8 Aug 2025 17:51:45 -0700 Subject: [PATCH 05/10] fix: to support backward compatibility for vs and eclipse, adding back modelSelection in chat-client (#2095) --- .../src/client/texts/modelSelection.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/chat-client/src/client/texts/modelSelection.ts b/chat-client/src/client/texts/modelSelection.ts index 4dcaad95ba..813d9e688f 100644 --- a/chat-client/src/client/texts/modelSelection.ts +++ b/chat-client/src/client/texts/modelSelection.ts @@ -1,10 +1,33 @@ import { ChatItem, ChatItemFormItem, ChatItemType } from '@aws/mynah-ui' +/** + * @deprecated use aws/chat/listAvailableModels server request instead + */ +export enum BedrockModel { + CLAUDE_SONNET_4_20250514_V1_0 = 'CLAUDE_SONNET_4_20250514_V1_0', + CLAUDE_3_7_SONNET_20250219_V1_0 = 'CLAUDE_3_7_SONNET_20250219_V1_0', +} + +type ModelDetails = { + label: string +} + +const modelRecord: Record = { + [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'claude-3.7-sonnet' }, + [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { label: 'claude-4-sonnet' }, +} + +const modelOptions = Object.entries(modelRecord).map(([value, { label }]) => ({ + value, + label, +})) + export const modelSelection: ChatItemFormItem = { type: 'select', id: 'model-selection', mandatory: true, hideMandatoryIcon: true, + options: modelOptions, border: false, autoWidth: true, } From 331eb2fcf5fce1571f1f59ea468129796478508c Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Mon, 11 Aug 2025 11:42:01 -0700 Subject: [PATCH 06/10] fix: check available models from backend before selecting default model from fallback models (#2102) --- .../src/language-server/agenticChat/agenticChatController.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 959941ea24..e58831c4fa 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -783,8 +783,8 @@ export class AgenticChatController implements ChatHandlers { if (models.some(model => model.id === modelId)) { selectedModelId = modelId } - // Priority 2: Use mapped version if modelId exists in FALLBACK_MODEL_RECORD - else if (modelId in FALLBACK_MODEL_RECORD) { + // Priority 2: Use mapped version if modelId exists in FALLBACK_MODEL_RECORD and no backend models available + else if (models.length === 0 && modelId in FALLBACK_MODEL_RECORD) { selectedModelId = getModelLabel(modelId) } // Priority 3: Fall back to default or system default From 13ab1c858e17e6416215aaeb1eb428d3ee9ebc13 Mon Sep 17 00:00:00 2001 From: Tai Lai Date: Wed, 13 Aug 2025 20:43:31 -0700 Subject: [PATCH 07/10] feat(amazonq): use model display names (#2123) --- chat-client/src/client/texts/modelSelection.ts | 4 ++-- .../agenticChat/agenticChatController.test.ts | 4 ++-- .../agenticChat/agenticChatController.ts | 18 ++++++++++++------ .../constants/modelSelection.test.ts | 6 +++--- .../agenticChat/constants/modelSelection.ts | 9 +++++++-- 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/chat-client/src/client/texts/modelSelection.ts b/chat-client/src/client/texts/modelSelection.ts index 813d9e688f..a1d0247875 100644 --- a/chat-client/src/client/texts/modelSelection.ts +++ b/chat-client/src/client/texts/modelSelection.ts @@ -13,8 +13,8 @@ type ModelDetails = { } const modelRecord: Record = { - [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'claude-3.7-sonnet' }, - [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { label: 'claude-4-sonnet' }, + [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'Claude 3.7 Sonnet' }, + [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { label: 'Claude Sonnet 4' }, } const modelOptions = Object.entries(modelRecord).map(([value, { label }]) => ({ 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 a7fc385a5a..0d071a4dac 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 @@ -3224,9 +3224,9 @@ ${' '.repeat(8)}} const result = await chatController.onListAvailableModels({ tabId: mockTabId }) - assert.strictEqual(result.selectedModelId, 'claude-4-sonnet') // FALLBACK_MODEL_RECORD[DEFAULT_MODEL_ID].label + assert.strictEqual(result.selectedModelId, 'claude-sonnet-4') // FALLBACK_MODEL_RECORD[DEFAULT_MODEL_ID].label // Verify session modelId is updated - assert.strictEqual(session.modelId, 'claude-4-sonnet') + assert.strictEqual(session.modelId, 'claude-sonnet-4') }) }) }) 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 e58831c4fa..73ef27192e 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -220,7 +220,7 @@ import { Message as DbMessage, messageToStreamingMessage, } from './tools/chatDb/util' -import { FALLBACK_MODEL_OPTIONS, FALLBACK_MODEL_RECORD } from './constants/modelSelection' +import { FALLBACK_MODEL_OPTIONS, FALLBACK_MODEL_RECORD, BEDROCK_MODEL_TO_MODEL_ID } from './constants/modelSelection' import { DEFAULT_IMAGE_VERIFICATION_OPTIONS, verifyServerImage } from '../../shared/imageVerification' import { sanitize } from '@aws/lsp-core/out/util/path' import { ActiveUserTracker } from '../../shared/activeUserTracker' @@ -777,11 +777,17 @@ export class AgenticChatController implements ChatHandlers { const getModelLabel = (modelKey: string) => FALLBACK_MODEL_RECORD[modelKey as keyof typeof FALLBACK_MODEL_RECORD]?.label || modelKey + // Helper function to map enum model ID to API model ID + const getMappedModelId = (modelKey: string) => + BEDROCK_MODEL_TO_MODEL_ID[modelKey as keyof typeof BEDROCK_MODEL_TO_MODEL_ID] || modelKey + // Determine selected model ID based on priority if (modelId) { - // Priority 1: Use modelId if it exists in available models from backend - if (models.some(model => model.id === modelId)) { - selectedModelId = modelId + const mappedModelId = getMappedModelId(modelId) + + // Priority 1: Use mapped modelId if it exists in available models from backend + if (models.some(model => model.id === mappedModelId)) { + selectedModelId = mappedModelId } // Priority 2: Use mapped version if modelId exists in FALLBACK_MODEL_RECORD and no backend models available else if (models.length === 0 && modelId in FALLBACK_MODEL_RECORD) { @@ -789,11 +795,11 @@ export class AgenticChatController implements ChatHandlers { } // Priority 3: Fall back to default or system default else { - selectedModelId = defaultModelId || getModelLabel(DEFAULT_MODEL_ID) + selectedModelId = defaultModelId || getMappedModelId(DEFAULT_MODEL_ID) } } else { // No user-selected model - use API default or system default - selectedModelId = defaultModelId || getModelLabel(DEFAULT_MODEL_ID) + selectedModelId = defaultModelId || getMappedModelId(DEFAULT_MODEL_ID) } // Store the selected model in the session diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts index dbf694b876..f037470bec 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts @@ -9,7 +9,7 @@ describe('modelSelection', () => { // Check that the array contains the expected models const modelIds = FALLBACK_MODEL_OPTIONS.map(model => model.id) - assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'), 'Should include claude-4-sonnet') + assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'), 'Should include claude-sonnet-4') assert.ok(modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0'), 'Should include claude-3.7-sonnet') // Check that each model has the required properties @@ -24,8 +24,8 @@ describe('modelSelection', () => { const claudeSonnet4 = FALLBACK_MODEL_OPTIONS.find(model => model.id === 'CLAUDE_SONNET_4_20250514_V1_0') const claudeSonnet37 = FALLBACK_MODEL_OPTIONS.find(model => model.id === 'CLAUDE_3_7_SONNET_20250219_V1_0') - assert.strictEqual(claudeSonnet4?.name, 'claude-4-sonnet', 'claude-4-sonnet should have correct name') - assert.strictEqual(claudeSonnet37?.name, 'claude-3.7-sonnet', 'claude-3.7-sonnet should have correct name') + assert.strictEqual(claudeSonnet4?.name, 'Claude Sonnet 4', 'claude-sonnet-4 should have correct name') + assert.strictEqual(claudeSonnet37?.name, 'Claude 3.7 Sonnet', 'claude-3.7-sonnet should have correct name') }) }) }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts index 0f3a14ff56..46c5446c8c 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts @@ -13,8 +13,13 @@ type ModelDetails = { } export const FALLBACK_MODEL_RECORD: Record = { - [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'claude-3.7-sonnet' }, - [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { label: 'claude-4-sonnet' }, + [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'Claude 3.7 Sonnet' }, + [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { label: 'Claude Sonnet 4' }, +} + +export const BEDROCK_MODEL_TO_MODEL_ID: Record = { + [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: 'claude-3.7-sonnet', + [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: 'claude-sonnet-4', } export const FALLBACK_MODEL_OPTIONS: ListAvailableModelsResult['models'] = Object.entries(FALLBACK_MODEL_RECORD).map( From 18111b079f9051d2b8cbfba7a60da685d0e41b8a Mon Sep 17 00:00:00 2001 From: Tai Lai Date: Mon, 18 Aug 2025 11:21:10 -0700 Subject: [PATCH 08/10] fix: cached model list should be invalidated on sign out (#2131) * fix: cached model list should be invalidated on sign out * fix test * avoid throwing error --- .../agenticChat/agenticChatController.ts | 2 +- .../agenticChat/tools/chatDb/chatDb.test.ts | 69 ++++++++++++++++++- .../agenticChat/tools/chatDb/chatDb.ts | 17 +++++ .../AmazonQTokenServiceManager.ts | 5 ++ 4 files changed, 91 insertions(+), 2 deletions(-) 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 73ef27192e..abcd8ab8ed 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -349,7 +349,7 @@ export class AgenticChatController implements ChatHandlers { // @ts-ignore this.#features.chat.chatOptionsUpdate({ region }) }) - this.#chatHistoryDb = new ChatDatabase(features) + this.#chatHistoryDb = ChatDatabase.getInstance(features) this.#tabBarController = new TabBarController( features, this.#chatHistoryDb, diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.test.ts index f4f61f955b..aed5a30643 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.test.ts @@ -57,7 +57,7 @@ describe('ChatDatabase', () => { }, } as unknown as Features - chatDb = new ChatDatabase(mockFeatures) + chatDb = ChatDatabase.getInstance(mockFeatures) }) afterEach(() => { @@ -665,6 +665,73 @@ describe('ChatDatabase', () => { ) }) }) + + describe('Model Cache Management', () => { + beforeEach(async () => { + await chatDb.databaseInitialize(0) + }) + + it('should cache and retrieve models', () => { + const models = [{ id: 'model-1', name: 'Test Model' }] + const defaultModelId = 'model-1' + + chatDb.setCachedModels(models, defaultModelId) + const cached = chatDb.getCachedModels() + + assert.ok(cached, 'Should return cached data') + assert.deepStrictEqual(cached.models, models) + assert.strictEqual(cached.defaultModelId, defaultModelId) + assert.ok(cached.timestamp > 0, 'Should have timestamp') + }) + + it('should validate cache expiry', () => { + const models = [{ id: 'model-1', name: 'Test Model' }] + chatDb.setCachedModels(models) + + // Mock isCachedValid to return false (expired) + const isCachedValidStub = sinon.stub(util, 'isCachedValid').returns(false) + + assert.strictEqual(chatDb.isCachedModelsValid(), false) + + isCachedValidStub.restore() + }) + + it('should clear cached models', () => { + const models = [{ id: 'model-1', name: 'Test Model' }] + chatDb.setCachedModels(models) + + // Verify cache exists + assert.ok(chatDb.getCachedModels(), 'Cache should exist before clearing') + + chatDb.clearCachedModels() + + // Verify cache is cleared + assert.strictEqual(chatDb.getCachedModels(), undefined, 'Cache should be cleared') + }) + + it('should clear model cache via static method when instance exists', () => { + const models = [{ id: 'model-1', name: 'Test Model' }] + chatDb.setCachedModels(models) + + // Verify cache exists + assert.ok(chatDb.getCachedModels(), 'Cache should exist before clearing') + + ChatDatabase.clearModelCache() + + // Verify cache is cleared + assert.strictEqual(chatDb.getCachedModels(), undefined, 'Cache should be cleared via static method') + }) + + it('should handle static clearModelCache when no instance exists', () => { + // Close current instance + chatDb.close() + + // Should not throw when no instance exists + assert.doesNotThrow(() => { + ChatDatabase.clearModelCache() + }, 'Should not throw when no instance exists') + }) + }) }) function uuid(): `${string}-${string}-${string}-${string}-${string}` { throw new Error('Function not implemented.') diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.ts index 4b169c8a69..2e4f7a099d 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.ts @@ -123,6 +123,12 @@ export class ChatDatabase { return ChatDatabase.#instance } + public static clearModelCache(): void { + if (ChatDatabase.#instance) { + ChatDatabase.#instance.clearCachedModels() + } + } + public close() { this.#db.close() ChatDatabase.#instance = undefined @@ -1116,6 +1122,17 @@ export class ChatDatabase { return isCachedValid(cachedData.timestamp) } + clearCachedModels(): void { + const existingSettings = this.getSettings() || { modelId: undefined } + this.updateSettings({ + ...existingSettings, + cachedModels: undefined, + cachedDefaultModelId: undefined, + modelCacheTimestamp: undefined, + }) + this.#features.logging.log('Model cache cleared') + } + getPairProgrammingMode(): boolean | undefined { const settings = this.getSettings() return settings?.pairProgrammingMode diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts index 8db60a69ab..c418fe29ca 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts @@ -33,6 +33,7 @@ import { getAmazonQRegionAndEndpoint } from './configurationUtils' import { getUserAgent } from '../telemetryUtils' import { StreamingClientServiceToken } from '../streamingClientService' import { parse } from '@aws-sdk/util-arn-parser' +import { ChatDatabase } from '../../language-server/agenticChat/tools/chatDb/chatDb' /** * AmazonQTokenServiceManager manages state and provides centralized access to @@ -147,6 +148,10 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager< if (type === 'iam') { return } + + // Clear model cache when credentials are deleted + ChatDatabase.clearModelCache() + this.cancelActiveProfileChangeToken() this.resetCodewhispererService() From 447f6cb44a8a0b486be544786eb201a0140146be Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Tue, 26 Aug 2025 08:42:18 -0700 Subject: [PATCH 09/10] fix: adding default modelId and setting cache ttl to 30 minutes (#2161) * fix: adding defaultmodelId and setting cache ttl to 30 minutes * fix: fixing tests * fix: updating comments --- .../agenticChat/agenticChatController.test.ts | 2 +- .../language-server/agenticChat/agenticChatController.ts | 6 +++--- .../src/language-server/agenticChat/tools/chatDb/util.ts | 2 +- 3 files changed, 5 insertions(+), 5 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 0d071a4dac..a0f5d8a3a8 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 @@ -3196,7 +3196,7 @@ ${' '.repeat(8)}} const result = await chatController.onListAvailableModels({ tabId: 'invalid-tab' }) assert.strictEqual(result.tabId, 'invalid-tab') - assert.strictEqual(result.selectedModelId, DEFAULT_MODEL_ID) + assert.strictEqual(result.selectedModelId, 'model1') getSessionStub.restore() }) 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 abcd8ab8ed..52ae5324f2 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -714,7 +714,7 @@ export class AgenticChatController implements ChatHandlers { }) // Wait for the response to be completed before proceeding - this.#log('Model Response', responseResult.toString()) + this.#log('Model Response: ', JSON.stringify(responseResult, null, 2)) models = Object.values(responseResult.models).map(({ modelId, modelName }) => ({ id: modelId, name: modelName ?? modelId, @@ -755,7 +755,7 @@ export class AgenticChatController implements ChatHandlers { // Get the first fallback model option as default const defaultModelOption = FALLBACK_MODEL_OPTIONS[1] - const DEFAULT_MODEL_ID = defaultModelOption?.id || Object.keys(FALLBACK_MODEL_RECORD)[1] + const DEFAULT_MODEL_ID = defaultModelId || defaultModelOption?.id const sessionResult = this.#chatSessionManagementService.getSession(params.tabId) const { data: session, success } = sessionResult @@ -769,7 +769,7 @@ export class AgenticChatController implements ChatHandlers { } } - // Determine which model ID to select based on priority. + // Determine selected model ID based on priority let selectedModelId: string let modelId = this.#chatHistoryDb.getModelId() diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts index 5a2f989f0c..77496cd96b 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts @@ -138,7 +138,7 @@ export type MessagesWithCharacterCount = { export function isCachedValid(timestamp: number): boolean { const currentTime = Date.now() const cacheAge = currentTime - timestamp - const CACHE_TTL = 5 * 60 * 1000 // 5 minutes in milliseconds + const CACHE_TTL = 30 * 60 * 1000 // 30 minutes in milliseconds return cacheAge < CACHE_TTL } From 433d9caea2022b7d94464611217047c456a5590c Mon Sep 17 00:00:00 2001 From: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:04:59 -0700 Subject: [PATCH 10/10] fix: Merge from main (#2169) * refactor: split edit from completion code path (#2058) * feat(amazonq): add two more tips for the did you know section (#2063) * fix(amazonq): make display findings tool run more often (#2067) Co-authored-by: Blake Lazarine * fix(amazonq): fix to add disable/enable feature back to mcp servers (#2052) * fix(amazonq): fix to add disable/enable feature back to mcp servers * fix(amazonq): fix to add unit tests * fix: fix to add unit test coverage for mcpUtils * feat(amazonq): enable sonnet 4 for fra region (#2069) * chore(release): release packages from branch main (#2050) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * fix(amazonq): emit failed status for amazonq_invokeLLM (#2071) * chore: generate completion log string (#2072) * fix(amazonq): fix fallout of #2051 (#2057) * fix(amazonq): fix fallout of #2051 avoid buggy global states * fix(amazonq): fix tests fix should report UTD test case * chore: merge agentic version 1.26.0 (#2076) * Bump agentic version: 1.26.0 * chore: format version json --------- Co-authored-by: aws-toolkit-automation <> * fix(amazonq): bundle corect registry.node for windows (#2061) * chore: bump runtimes to 0.2.123 (#2082) * fix: remove edit cache logic (#2079) * fix(amazonq): add fallback classpath generation (#2077) * fix(amazonq): skips continuous monitoring when WCS sees workspace as idle (#2066) * fix(amazonq): skips continuous monitoring when WCS sees workspace as idle * fix(amazonq): skips creating remote workspace at the start --------- Co-authored-by: Jiatong Li * fix: sessionManager misused because there are 2 types of manager now (#2090) * chore: mapping adt plugin to abap to let inline suggestion work (#2085) * chore: support inline suggestion in adt plugin for eclipse * fix: add import URI * chore: add detailed error description for uri is empty for language id * feat(amazonq): read tool ui revamp * feat(amazonq): read tool message revamp (#2049) * feat(amazonq): read tool message revamp * fix tests * feat: file search ui (#2078) * feat: file search ui * fix tests * fix integration tests * remove unnecessary type check * fix: use quotes instead of backticks * fix: creating a new sesion for Edits trigger with next token (#2094) * chore: bump @aws/mynah-ui to 4.36.4 (#2096) * fix(amazonq): leverage lcs to find the chars added and removed (#2092) * fix: update client name to support Sagemaker AI origin for agentic chat (#2093) Co-authored-by: chungjac * chore(release): release packages from branch main (#2073) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore: format version.json after incrementing (#2068) * chore: merge agentic version 1.27.0 (#2107) * Bump agentic version: 1.27.0 * Revert "feat(amazonq): read tool ui revamp" This reverts commit c65428bab2cf5e47badf1e3a9453babcf881e60c. * fix: the style in version json (#2106) --------- Co-authored-by: aws-toolkit-automation <> Co-authored-by: Tai Lai Co-authored-by: Christopher Christou <39839589+awschristou@users.noreply.github.com> * fix(amazonq): persist mcp configs in agent json on start-up (#2112) * feat(amazonq): added mcp admin level configuration with GetProfile (#2000) * feat(amazonq): added mcp admin level configuration with GetProfile * feat(amazonq): added UX message for mcp admin control * test: add unit tests for ProfileStatusMonitor static functionality * test: add comprehensive unit tests for MCP admin control features * fix: fix to wait for serviceManager is initialzied to initialize mcp managers * fix: fix for unit test failures * fix: fix for UI changes * fix(amazonq): fix to to rename mcp enabled function and max time limit of 10 seconds * fix: fix to add async initialization for mcp manager * fix: fix to move action buttons to mcpEventHandler for listMcpServers * fix: added try and catch block for mcp initialization * fix: fix for merge conflicts * fix: fix for test failure * fix: remove the unnecessary feature flag * fix: fix for mynah test failure * fix: fix to retry function to common util * fix: fix to add retryUtils * fix: Use file context override in the inline completion params for Jupyter Notebook (#2114) * feat(amazonq): read tool ui revamp (#2113) (#2121) * feat(amazonq): read tool ui revamp * feat(amazonq): read tool message revamp (#2049) * feat(amazonq): read tool message revamp * fix tests * feat: file search ui (#2078) * feat: file search ui * fix tests * fix integration tests * remove unnecessary type check * fix: use quotes instead of backticks * fix header update issue * fix integration test * refactor: improve generateCompletion logging format (#2125) * fix(amazonq): handle case where multiple rules are provided with the same name (#2118) * fix(amazonq): handle case where multiple rules are provided with the same name * fix(amazonq): add unit test for duplicate custom guidelines * fix(amazonq): add unit test for processToolUses * fix(amazonq): set context type for AmazonQ.md to rule * fix(amazonq): add README.md back as rule context type --------- Co-authored-by: Blake Lazarine * perf: remove edit completion retry mechanism on document change (#2124) * ci: remove need for custom PAT for release branch workflow (#2126) The token from GitHub Actions is sufficient * fix: proper path handling for additional context (#2129) * fix: proper pathing for additonal context * fix: update existing tests to also mock path.join() * fix(amazonq): fix regression of mcp config in agent config (#2101) * fix(amazonq): update process-permission-updates to be the same as previous behavior * fix(chat-client): update package.json chat-client to prod * fix(amazonq): retain mcp permissions after disable/enable server * fix: dont call initOneServer on Built-in * fix: deny permission does not persist after restart IDE --------- Co-authored-by: Boyu * feat: remove project type validation from LSP layer (#2103) * feat: remove project type validation from LSP layer * refactor: remove unused validation functions from LSP layer * fix: fix for button text and remove profilearn caching (#2137) * chore: bump runtimes to 0.2.126 (#2138) * fix: fix to add disk caching for mcp admin state (#2139) * fix: fix to add disk caching for mcp admin state * fix: fix to add logging * fix: fix to initialize mcpManager in any case and discover servers based on mcpState * fix: fix for unit test failure * refactor: cleanup old nep code path which is no longered being used in codewhispererServer.ts (#2141) * fix(amazonq): add server side control for WCS features (#2128) Co-authored-by: Jiatong Li * chore: update STE userContext version metadata (#2142) * fix: fix to turn on and off MCP servers incase of error based on last state (#2143) Co-authored-by: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> * fix: empty userTriggerDecision not being sent for NEP code path (#2140) * refactor: clean up old NEP code path which is no longer used * fix: empty userTriggerDecision not being sent for NEP code path * test: disable telemetry test temporarily * fix: patch * revert: a * revert: a * chore(release): release packages from branch main (#2117) * fix(amazonq): don't let flare send discard for the still valid suggestion in JB (#2145) * fix(amazonq): don't let flare send discard for the still valid suggestion in JB if a valid sessionId is returned back to JB, JB will eventually send a decision for it, but when user types a character to reject the current suggestion, on JB side it will 1) send reject for the current one 2) send a new trigger for the latest context. 2) will happen before 1) which will discard the current active session on flare's end. We don't want flare to do that for JB. * fix(amazonq): createSession doesn't need to close the previous session it should be handled together with telemetry reporting * fix(amazonq): fix test * fix(amazonq): test fix attempt * fix(amazonq): test fix attempt * chore(release): release packages from branch main (#2149) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * chore: merge agentic version 1.29.0 (#2151) * chore: bump agentic version: 1.29.0 * chore: empty commit to start Github Action (#2150) --------- Co-authored-by: aws-toolkit-automation <> * feat: add basic OAuth client for remote MCP (#2136) * feat: add basic mcp oauth client * fix: relax condition for triggering oauth flow; add resilience checks for as availability * feat: add unit test for auth client * fix: multiple fixes for remote mcp error and timeout * fix: adding streakTracker to track streakLength across Completions and Edits (#2147) * feat: disable pkce flow during plugin load (#2153) * chore: bump runtimes to 0.2.127 (#2156) * fix: multiple fixes on auth flow edge cases (#2155) * feat: update MCP manager and utilities (#2158) Co-authored-by: Boyu * fix(amazonq): disable typewriter animation (#2160) * ci: generation of builds action can be triggered manually (#2157) Co-authored-by: Laxman Reddy <141967714+laileni-aws@users.noreply.github.com> * fix(amazonq): fix for mcp servers operations to edit server config only (#2165) * fix(amazonq): fix for mcp servers operations to edit server specific config * fix(amazonq): additional mcp server config fixes * fix: resolve test failures * fix: update MCP manager configuration * fix: allow ci to run on release branches (#2159) Currently release/agentic/* branches used in our release process have rules that require CI to be run. However the corresponding workflows do not actually trigger for release branches. This change ensures no manual overrides are required in the release process and CI mapped with the branch actually runs. * Updating the model of listAvailableModels (#2064) * fix(amazonq): fix flickering issue for model selection dropdown and agenticCoding toggle (#2065) * fix(amazonq): fix flickering issue for modelId and agenticCoding * fix(amazonq): Fixing flaky tests * feat(amazonq): Fetching models from backend and adding cache implementation. (#2075) * fix: removing and refactoring legacy code before implementing model selection * feat(amazonq): adding cache implementation and fetching models from listAvailableModels api * feat(amazonq): adding selected model in error case * feat(amazonq): adding test cases * fix: addressing comments * fix: fixing test cases and adding modelName to models * fix: minor edits * fix: minor edits * fix: minor modifications in logs * fix: adding default model if api throws any errors * fix: refactoring code * fix: Improve model selection fallback logic when user's preferred model is unavailable (#2089) * fix: if user preferred model does not exist, fall back to default model * fix: minor test changes * fix: to support backward compatibility for vs and eclipse, adding back modelSelection in chat-client (#2095) * fix: check available models from backend before selecting default model from fallback models (#2102) * feat(amazonq): use model display names (#2123) * fix: cached model list should be invalidated on sign out (#2131) * fix: cached model list should be invalidated on sign out * fix test * avoid throwing error * fix: adding default modelId and setting cache ttl to 30 minutes (#2161) * fix: adding defaultmodelId and setting cache ttl to 30 minutes * fix: fixing tests * fix: updating comments * fix: lint issue while resolving merge conflicts --------- Co-authored-by: Will Lo <96078566+Will-ShaoHua@users.noreply.github.com> Co-authored-by: abhraina-aws Co-authored-by: BlakeLazarine Co-authored-by: Blake Lazarine Co-authored-by: invictus <149003065+ashishrp-aws@users.noreply.github.com> Co-authored-by: Tai Lai Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Na Yue Co-authored-by: andrewyuq <89420755+andrewyuq@users.noreply.github.com> Co-authored-by: Sherry Lu <75588211+XiaoxuanLu@users.noreply.github.com> Co-authored-by: tsmithsz <84354541+tsmithsz@users.noreply.github.com> Co-authored-by: liumofei-amazon <98127670+liumofei-amazon@users.noreply.github.com> Co-authored-by: Jiatong Li Co-authored-by: Jiatong Li Co-authored-by: atontb <104926752+atonaamz@users.noreply.github.com> Co-authored-by: Jayakrishna P Co-authored-by: chungjac Co-authored-by: Christopher Christou <39839589+awschristou@users.noreply.github.com> Co-authored-by: Tai Lai Co-authored-by: Dung Dong Co-authored-by: Lei Gao <97199248+leigaol@users.noreply.github.com> Co-authored-by: Richard Li <742829+rli@users.noreply.github.com> Co-authored-by: Boyu Co-authored-by: Rajanna-Karthik Co-authored-by: manodnyab <66754471+manodnyab@users.noreply.github.com> Co-authored-by: Shruti Sinha <44882001+shruti0085@users.noreply.github.com> --- .github/workflows/create-agent-standalone.yml | 2 + .../create-release-candidate-branch.yml | 21 +- .github/workflows/lsp-ci.yaml | 4 +- .github/workflows/npm-packaging.yaml | 4 +- .release-please-manifest.json | 14 +- app/aws-lsp-antlr4-runtimes/package.json | 2 +- app/aws-lsp-buildspec-runtimes/package.json | 2 +- .../package.json | 2 +- app/aws-lsp-codewhisperer-runtimes/README.md | 11 + .../registry-js/win32-x64/registry.node | Bin 0 -> 696320 bytes .../package.json | 5 +- .../src/version.json | 2 +- app/aws-lsp-identity-runtimes/package.json | 2 +- app/aws-lsp-json-runtimes/package.json | 2 +- .../package.json | 2 +- app/aws-lsp-partiql-runtimes/package.json | 2 +- app/aws-lsp-s3-runtimes/package.json | 2 +- app/aws-lsp-yaml-json-webworker/package.json | 2 +- app/aws-lsp-yaml-runtimes/package.json | 2 +- app/hello-world-lsp-runtimes/package.json | 2 +- chat-client/CHANGELOG.md | 34 ++ chat-client/package.json | 6 +- chat-client/src/client/mcpMynahUi.test.ts | 11 +- chat-client/src/client/mcpMynahUi.ts | 38 +- chat-client/src/client/mynahUi.ts | 17 +- chat-client/src/client/tabs/tabFactory.ts | 2 + client/vscode/package.json | 2 +- core/aws-lsp-core/CHANGELOG.md | 12 + core/aws-lsp-core/package.json | 4 +- core/aws-lsp-core/src/index.ts | 1 + core/aws-lsp-core/src/util/retryUtils.test.ts | 120 ++++ core/aws-lsp-core/src/util/retryUtils.ts | 77 +++ .../q-agentic-chat-server/package.json | 2 +- .../src/tests/agenticChatInteg.test.ts | 13 +- package-lock.json | 128 ++--- server/aws-lsp-antlr4/CHANGELOG.md | 14 + server/aws-lsp-antlr4/package.json | 6 +- server/aws-lsp-buildspec/package.json | 2 +- server/aws-lsp-cloudformation/package.json | 2 +- server/aws-lsp-codewhisperer/CHANGELOG.md | 79 +++ server/aws-lsp-codewhisperer/package.json | 6 +- .../client/token/bearer-token-service.json | 85 +++ .../token/codewhispererbearertokenclient.d.ts | 26 + .../agenticChat/agenticChatController.test.ts | 102 +++- .../agenticChat/agenticChatController.ts | 240 +++++--- .../agenticChat/agenticChatResultStream.ts | 63 +- .../agenticChat/constants/constants.ts | 2 + .../context/additionalContextProvider.test.ts | 117 ++++ .../context/additionalContextProvider.ts | 2 +- .../src/language-server/agenticChat/errors.ts | 1 + .../agenticChat/qAgenticChatServer.ts | 2 +- .../agenticChat/tools/fileSearch.ts | 4 + .../tools/mcp/mcpEventHandler.test.ts | 106 ++++ .../agenticChat/tools/mcp/mcpEventHandler.ts | 261 +++++---- .../agenticChat/tools/mcp/mcpManager.test.ts | 232 +++++++- .../agenticChat/tools/mcp/mcpManager.ts | 406 +++++++++---- .../tools/mcp/mcpOauthClient.test.ts | 122 ++++ .../agenticChat/tools/mcp/mcpOauthClient.ts | 491 ++++++++++++++++ .../agenticChat/tools/mcp/mcpTypes.ts | 1 + .../agenticChat/tools/mcp/mcpUtils.test.ts | 321 +++++++++++ .../agenticChat/tools/mcp/mcpUtils.ts | 219 ++++--- .../tools/mcp/profileStatusMonitor.test.ts | 182 ++++++ .../tools/mcp/profileStatusMonitor.ts | 182 ++++++ .../tools/qCodeAnalysis/codeReview.test.ts | 28 + .../tools/qCodeAnalysis/codeReview.ts | 5 + .../qCodeAnalysis/displayFindingsConstants.ts | 1 + .../agenticChat/tools/toolServer.ts | 166 +++++- .../src/language-server/chat/qChatServer.ts | 4 +- .../inline-completion/codePercentage.ts | 14 +- .../codeWhispererServer.nep.test.ts | 2 + .../codeWhispererServer.test.ts | 167 +++--- .../inline-completion/codeWhispererServer.ts | 537 ++++++++---------- .../inline-completion/constants.ts | 27 + .../inline-completion/diffUtils.ts | 67 +++ .../documentChangedListener.ts | 19 + .../editCompletionHandler.ts | 430 ++++++++++++++ .../session/sessionManager.test.ts | 70 +-- .../session/sessionManager.ts | 59 +- .../inline-completion/telemetry.ts | 48 +- .../tracker/streakTracker.test.ts | 85 +++ .../tracker/streakTracker.ts | 42 ++ .../inline-completion/trigger.ts | 57 ++ .../userTriggerDecision.test.ts | 36 +- .../resources/SupportedProjects.ts | 3 + .../netTransform/tests/validation.test.ts | 120 +--- .../netTransform/transformHandler.ts | 12 - .../netTransform/validation.ts | 30 +- .../IdleWorkspaceManager.test.ts | 75 +++ .../workspaceContext/IdleWorkspaceManager.ts | 42 ++ .../workspaceContext/artifactManager.ts | 15 + .../dependency/dependencyDiscoverer.ts | 3 +- .../workspaceContextServer.ts | 17 +- .../workspaceFolderManager.test.ts | 163 ++++-- .../workspaceFolderManager.ts | 248 +++++--- .../AmazonQTokenServiceManager.ts | 19 + .../BaseAmazonQServiceManager.ts | 5 + .../src/shared/codeWhispererService.test.ts | 26 + .../src/shared/codeWhispererService.ts | 208 ++++++- .../shared/telemetry/telemetryService.test.ts | 12 +- .../src/shared/telemetry/telemetryService.ts | 17 +- .../src/shared/telemetryUtils.test.ts | 30 +- .../src/shared/telemetryUtils.ts | 13 +- .../src/shared/utils.test.ts | 18 +- .../aws-lsp-codewhisperer/src/shared/utils.ts | 7 +- server/aws-lsp-identity/package.json | 2 +- server/aws-lsp-json/CHANGELOG.md | 14 + server/aws-lsp-json/package.json | 6 +- server/aws-lsp-notification/package.json | 2 +- server/aws-lsp-partiql/CHANGELOG.md | 7 + server/aws-lsp-partiql/package.json | 4 +- server/aws-lsp-s3/package.json | 2 +- server/aws-lsp-yaml/CHANGELOG.md | 14 + server/aws-lsp-yaml/package.json | 6 +- server/device-sso-auth-lsp/package.json | 2 +- server/hello-world-lsp/package.json | 2 +- 115 files changed, 5309 insertions(+), 1527 deletions(-) create mode 100644 app/aws-lsp-codewhisperer-runtimes/_bundle-assets/registry-js/win32-x64/registry.node create mode 100644 core/aws-lsp-core/src/util/retryUtils.test.ts create mode 100644 core/aws-lsp-core/src/util/retryUtils.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.test.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.test.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/inline-completion/documentChangedListener.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.test.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/inline-completion/trigger.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/workspaceContext/IdleWorkspaceManager.test.ts create mode 100644 server/aws-lsp-codewhisperer/src/language-server/workspaceContext/IdleWorkspaceManager.ts diff --git a/.github/workflows/create-agent-standalone.yml b/.github/workflows/create-agent-standalone.yml index 2807a50aea..f50b5b43b0 100644 --- a/.github/workflows/create-agent-standalone.yml +++ b/.github/workflows/create-agent-standalone.yml @@ -3,10 +3,12 @@ name: Create agent-standalone bundles on: push: branches: [main, feature/*, release/agentic/*] + workflow_dispatch: jobs: build: runs-on: ubuntu-latest + if: github.event_name == 'push' || github.actor_id == github.repository_owner_id steps: - name: Checkout repository diff --git a/.github/workflows/create-release-candidate-branch.yml b/.github/workflows/create-release-candidate-branch.yml index aa37873d2a..b447f11803 100644 --- a/.github/workflows/create-release-candidate-branch.yml +++ b/.github/workflows/create-release-candidate-branch.yml @@ -28,14 +28,15 @@ jobs: setupRcBranch: name: Set up a Release Candidate Branch runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Sync code uses: actions/checkout@v4 with: ref: ${{ inputs.commitId }} - # Use RELEASE_CANDIDATE_BRANCH_CREATION_PAT to ensure workflow triggering works - token: ${{ secrets.RELEASE_CANDIDATE_BRANCH_CREATION_PAT }} + token: ${{ secrets.GITHUB_TOKEN }} persist-credentials: true - name: Setup Node.js @@ -44,6 +45,10 @@ jobs: node-version: '20' cache: 'npm' + # Needed to format the json file being checked in + - name: Install dependencies + run: npm ci + - name: Calculate Release Version id: release-version env: @@ -87,6 +92,9 @@ jobs: git add "$VERSION_FILE" + # Ensure the file does not cause issues when merged to main + npm run format-staged + - name: Create Release Candidate Branch id: release-branch env: @@ -102,15 +110,8 @@ jobs: env: BRANCH_NAME: ${{ steps.release-branch.outputs.BRANCH_NAME }} RELEASE_VERSION: ${{ steps.release-version.outputs.RELEASE_VERSION }} - # We use the toolkit-automation account, basically something that - # isn't the default GitHub Token, because you cannot chain actions with that. - # In our case, after pushing a commit (below), we want create-agent-standalone.yml - # to start automatically. - REPO_PAT: ${{ secrets.RELEASE_CANDIDATE_BRANCH_CREATION_PAT }} run: | git config --global user.email "<>" git config --global user.name "aws-toolkit-automation" - # Configure git to use the PAT token for authentication - git remote set-url origin "https://x-access-token:${REPO_PAT}@github.com/${{ github.repository }}.git" - git commit -m "Bump agentic version: $RELEASE_VERSION" + git commit --no-verify -m "chore: bump agentic version: $RELEASE_VERSION" git push --set-upstream origin "$BRANCH_NAME" diff --git a/.github/workflows/lsp-ci.yaml b/.github/workflows/lsp-ci.yaml index bda44ec74a..15f87d2428 100644 --- a/.github/workflows/lsp-ci.yaml +++ b/.github/workflows/lsp-ci.yaml @@ -1,9 +1,9 @@ name: Language Server CI on: push: - branches: [main, dev, feature/*] + branches: [main, dev, feature/*, release/agentic/*] pull_request: - branches: [main, dev, feature/*] + branches: [main, dev, feature/*, release/agentic/*] jobs: test: diff --git a/.github/workflows/npm-packaging.yaml b/.github/workflows/npm-packaging.yaml index 724c9a0c05..d4ce77e07c 100644 --- a/.github/workflows/npm-packaging.yaml +++ b/.github/workflows/npm-packaging.yaml @@ -1,9 +1,9 @@ name: NPM Packaging on: push: - branches: [main, dev, feature/*] + branches: [main, dev, feature/*, release/agentic/*] pull_request: - branches: [main, dev, feature/*] + branches: [main, dev, feature/*, release/agentic/*] jobs: build: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 61dc88f5e1..49f118f219 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,9 +1,9 @@ { - "chat-client": "0.1.30", - "core/aws-lsp-core": "0.0.13", - "server/aws-lsp-antlr4": "0.1.17", - "server/aws-lsp-codewhisperer": "0.0.71", - "server/aws-lsp-json": "0.1.17", - "server/aws-lsp-partiql": "0.0.16", - "server/aws-lsp-yaml": "0.1.17" + "chat-client": "0.1.33", + "core/aws-lsp-core": "0.0.14", + "server/aws-lsp-antlr4": "0.1.18", + "server/aws-lsp-codewhisperer": "0.0.75", + "server/aws-lsp-json": "0.1.18", + "server/aws-lsp-partiql": "0.0.17", + "server/aws-lsp-yaml": "0.1.18" } diff --git a/app/aws-lsp-antlr4-runtimes/package.json b/app/aws-lsp-antlr4-runtimes/package.json index e283033afb..89fa95dcb3 100644 --- a/app/aws-lsp-antlr4-runtimes/package.json +++ b/app/aws-lsp-antlr4-runtimes/package.json @@ -12,7 +12,7 @@ "webpack": "webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-antlr4": "*", "antlr4-c3": "^3.4.1", "antlr4ng": "^3.0.4" diff --git a/app/aws-lsp-buildspec-runtimes/package.json b/app/aws-lsp-buildspec-runtimes/package.json index 0fa2217def..0ad07ddb8f 100644 --- a/app/aws-lsp-buildspec-runtimes/package.json +++ b/app/aws-lsp-buildspec-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-buildspec": "^0.0.1" } } diff --git a/app/aws-lsp-cloudformation-runtimes/package.json b/app/aws-lsp-cloudformation-runtimes/package.json index 336e9b48ae..d211149d0d 100644 --- a/app/aws-lsp-cloudformation-runtimes/package.json +++ b/app/aws-lsp-cloudformation-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-cloudformation": "^0.0.1" } } diff --git a/app/aws-lsp-codewhisperer-runtimes/README.md b/app/aws-lsp-codewhisperer-runtimes/README.md index 977d514bd6..71fbcf0456 100644 --- a/app/aws-lsp-codewhisperer-runtimes/README.md +++ b/app/aws-lsp-codewhisperer-runtimes/README.md @@ -76,6 +76,17 @@ The server is managed via scripts/dev-server.js, which ensures: **NOTE**: Tests are currently disabled for Windows as we currently face issues with automatically shutting down devserver and cleaning resources after tests are executed. +## Binary Dependencies + +### registry.node +The file `_bundle-assets/registry-js/win32-x64/registry.node` is a precompiled binary downloaded from the [registry-js](https://github.com/desktop/registry-js) project. + +- **Current version**: v1.16.1 (released May 21, 2024) +- **Source**: https://github.com/desktop/registry-js/releases +- **Purpose**: Provides Windows registry access functionality + +**To update**: Download the latest `registry.node` binary for win32-x64 from the registry-js releases page and replace the existing file. + #### Tests configuration - Test settings are defined in `wdio.conf.ts` - The actual test implementation is in the `test/e2e` folder diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/registry-js/win32-x64/registry.node b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/registry-js/win32-x64/registry.node new file mode 100644 index 0000000000000000000000000000000000000000..d7b5091a2cf02da166341422c54ec9520c2c1b31 GIT binary patch literal 696320 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%0A@3ZZhb^|vb3jk@;Yj9y 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 Q>tTD%WSIJhun)r!*waTEzH3_xazK#r^ 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

API to get code transformation status.

" }, + "GetProfile": { + "name": "GetProfile", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { + "shape": "GetProfileRequest" + }, + "output": { + "shape": "GetProfileResponse" + }, + "errors": [ + { + "shape": "ThrottlingException" + }, + { + "shape": "ResourceNotFoundException" + }, + { + "shape": "InternalServerException" + }, + { + "shape": "ValidationException" + }, + { + "shape": "AccessDeniedException" + } + ], + "documentation": "

Get the requested CodeWhisperer profile.

" + }, "GetUsageLimits": { "name": "GetUsageLimits", "http": { @@ -3126,6 +3157,24 @@ } } }, + "GetProfileRequest": { + "type": "structure", + "required": ["profileArn"], + "members": { + "profileArn": { + "shape": "ProfileArn" + } + } + }, + "GetProfileResponse": { + "type": "structure", + "required": ["profile"], + "members": { + "profile": { + "shape": "ProfileInfo" + } + } + }, "GetTaskAssistCodeGenerationRequest": { "type": "structure", "required": ["conversationId", "codeGenerationId"], @@ -3791,6 +3840,15 @@ "type": "long", "box": true }, + "MCPConfiguration": { + "type": "structure", + "required": ["toggle"], + "members": { + "toggle": { + "shape": "OptInFeatureToggle" + } + } + }, "MemoryEntry": { "type": "structure", "required": ["id", "memoryEntryString", "metadata"], @@ -4010,6 +4068,9 @@ }, "workspaceContext": { "shape": "WorkspaceContext" + }, + "mcpConfiguration": { + "shape": "MCPConfiguration" } } }, @@ -4204,6 +4265,30 @@ } } }, + "ProfileInfo": { + "type": "structure", + "required": ["arn"], + "members": { + "arn": { + "shape": "ProfileArn" + }, + "profileName": { + "shape": "ProfileName" + }, + "description": { + "shape": "ProfileDescription" + }, + "status": { + "shape": "ProfileStatus" + }, + "profileType": { + "shape": "ProfileType" + }, + "optInFeatures": { + "shape": "OptInFeatures" + } + } + }, "ProfileArn": { "type": "string", "max": 950, diff --git a/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts b/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts index 86798cce75..34aa384c14 100644 --- a/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts +++ b/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts @@ -145,6 +145,14 @@ declare class CodeWhispererBearerTokenClient extends Service { * API to get code transformation status. */ getTransformationPlan(callback?: (err: AWSError, data: CodeWhispererBearerTokenClient.Types.GetTransformationPlanResponse) => void): Request; + /** + * Get the requested CodeWhisperer profile. + */ + getProfile(params: CodeWhispererBearerTokenClient.Types.GetProfileRequest, callback?: (err: AWSError, data: CodeWhispererBearerTokenClient.Types.GetProfileResponse) => void): Request; + /** + * Get the requested CodeWhisperer profile. + */ + getProfile(callback?: (err: AWSError, data: CodeWhispererBearerTokenClient.Types.GetProfileResponse) => void): Request; /** * API to get current usage limits */ @@ -962,6 +970,12 @@ declare namespace CodeWhispererBearerTokenClient { jobStatus?: CodeFixJobStatus; suggestedFix?: SuggestedFix; } + export interface GetProfileRequest { + profileArn: ProfileArn; + } + export interface GetProfileResponse { + profile: ProfileInfo; + } export interface GetTaskAssistCodeGenerationRequest { conversationId: ConversationId; codeGenerationId: CodeGenerationId; @@ -1194,6 +1208,9 @@ declare namespace CodeWhispererBearerTokenClient { nextToken?: String; } export type Long = number; + export interface MCPConfiguration { + toggle: OptInFeatureToggle; + } export interface MemoryEntry { /** * A unique identifier for a single memory entry @@ -1270,6 +1287,7 @@ declare namespace CodeWhispererBearerTokenClient { dashboardAnalytics?: DashboardAnalytics; notifications?: Notifications; workspaceContext?: WorkspaceContext; + mcpConfiguration?: MCPConfiguration; } export type OptOutPreference = "OPTIN"|"OPTOUT"|string; export type Origin = "CHATBOT"|"CONSOLE"|"DOCUMENTATION"|"MARKETING"|"MOBILE"|"SERVICE_INTERNAL"|"UNIFIED_SEARCH"|"UNKNOWN"|"MD"|"IDE"|"SAGE_MAKER"|"CLI"|"AI_EDITOR"|"OPENSEARCH_DASHBOARD"|"GITLAB"|string; @@ -1325,6 +1343,14 @@ declare namespace CodeWhispererBearerTokenClient { permissionUpdateRequired?: Boolean; applicationProperties?: ApplicationPropertiesList; } + export interface ProfileInfo { + arn: ProfileArn; + profileName?: ProfileName; + description?: ProfileDescription; + status?: ProfileStatus; + profileType?: ProfileType; + optInFeatures?: OptInFeatures; + } export type ProfileArn = string; export type ProfileDescription = string; export type ProfileList = Profile[]; 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 a0f5d8a3a8..c1842a6008 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 @@ -68,6 +68,7 @@ import { McpManager } from './tools/mcp/mcpManager' import { AgenticChatResultStream } from './agenticChatResultStream' import { AgenticChatError } from './errors' import * as sharedUtils from '../../shared/utils' +import { IdleWorkspaceManager } from '../workspaceContext/IdleWorkspaceManager' describe('AgenticChatController', () => { let mcpInstanceStub: sinon.SinonStub @@ -241,7 +242,7 @@ describe('AgenticChatController', () => { testFeatures.agent = { runTool: sinon.stub().resolves({}), getTools: sinon.stub().returns( - ['mock-tool-name', 'mock-tool-name-1', 'mock-tool-name-2'].map(toolName => ({ + ['mock-tool-name', 'mock-tool-name-1', 'mock-tool-name-2', 'codeReview'].map(toolName => ({ toolSpecification: { name: toolName, description: 'Mock tool for testing' }, })) ), @@ -440,7 +441,7 @@ describe('AgenticChatController', () => { assert.deepStrictEqual(chatResult, { additionalMessages: [], - body: '\n\nHello World!', + body: '\nHello World!', messageId: 'mock-message-id', buttons: [], codeReference: [], @@ -465,6 +466,15 @@ describe('AgenticChatController', () => { assert.strictEqual(typeof session.conversationId, 'string') }) + it('invokes IdleWorkspaceManager recordActivityTimestamp', async () => { + const recordActivityTimestampStub = sinon.stub(IdleWorkspaceManager, 'recordActivityTimestamp') + + await chatController.onChatPrompt({ tabId: mockTabId, prompt: { prompt: 'Hello' } }, mockCancellationToken) + + sinon.assert.calledOnce(recordActivityTimestampStub) + recordActivityTimestampStub.restore() + }) + it('includes chat history from the database in the request input', async () => { // Mock chat history const mockHistory = [ @@ -1130,7 +1140,7 @@ describe('AgenticChatController', () => { sinon.assert.callCount(testFeatures.lsp.sendProgress, mockChatResponseList.length + 1) // response length + 1 loading messages assert.deepStrictEqual(chatResult, { additionalMessages: [], - body: '\n\nHello World!', + body: '\nHello World!', messageId: 'mock-message-id', codeReference: [], buttons: [], @@ -1149,7 +1159,7 @@ describe('AgenticChatController', () => { sinon.assert.callCount(testFeatures.lsp.sendProgress, mockChatResponseList.length + 1) // response length + 1 loading message assert.deepStrictEqual(chatResult, { additionalMessages: [], - body: '\n\nHello World!', + body: '\nHello World!', messageId: 'mock-message-id', buttons: [], codeReference: [], @@ -3361,6 +3371,90 @@ ${' '.repeat(8)}} assert.strictEqual(returnValue, undefined) }) }) + + describe('processToolUses', () => { + it('filters rule artifacts from additionalContext for CodeReview tool', async () => { + const mockAdditionalContext = [ + { + type: 'file', + description: '', + name: '', + relativePath: '', + startLine: 0, + endLine: 0, + path: '/test/file.js', + }, + { + type: 'rule', + description: '', + name: '', + relativePath: '', + startLine: 0, + endLine: 0, + path: '/test/rule1.json', + }, + { + type: 'rule', + description: '', + name: '', + relativePath: '', + startLine: 0, + endLine: 0, + path: '/test/rule2.json', + }, + ] + + const toolUse = { + toolUseId: 'test-id', + name: 'codeReview', + input: { fileLevelArtifacts: [{ path: '/test/file.js' }] }, + stop: true, + } + + const runToolStub = testFeatures.agent.runTool as sinon.SinonStub + runToolStub.resolves({}) + + // Create a mock session with toolUseLookup + const mockSession = { + toolUseLookup: new Map(), + pairProgrammingMode: true, + } as any + + // Create a minimal mock of AgenticChatResultStream + const mockChatResultStream = { + removeResultBlockAndUpdateUI: sinon.stub().resolves(), + writeResultBlock: sinon.stub().resolves(1), + overwriteResultBlock: sinon.stub().resolves(), + removeResultBlock: sinon.stub().resolves(), + getMessageBlockId: sinon.stub().returns(undefined), + hasMessage: sinon.stub().returns(false), + updateOngoingProgressResult: sinon.stub().resolves(), + getResult: sinon.stub().returns({ messageId: 'test', body: '' }), + setMessageIdToUpdateForTool: sinon.stub(), + getMessageIdToUpdateForTool: sinon.stub().returns(undefined), + addMessageOperation: sinon.stub(), + getMessageOperation: sinon.stub().returns(undefined), + } + + // Call processToolUses directly + await chatController.processToolUses( + [toolUse], + mockChatResultStream as any, + mockSession, + 'tabId', + mockCancellationToken, + mockAdditionalContext + ) + + // Verify runTool was called with ruleArtifacts + sinon.assert.calledOnce(runToolStub) + const toolInput = runToolStub.firstCall.args[1] + assert.ok(toolInput.ruleArtifacts) + assert.strictEqual(toolInput.ruleArtifacts.length, 2) + assert.strictEqual(toolInput.ruleArtifacts[0].path, '/test/rule1.json') + assert.strictEqual(toolInput.ruleArtifacts[1].path, '/test/rule2.json') + }) + }) }) // The body may include text-based progress updates from tool invocations. 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 52ae5324f2..8cdba3d291 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -167,7 +167,7 @@ import { ExecuteBash, ExecuteBashParams } from './tools/executeBash' import { ExplanatoryParams, InvokeOutput, ToolApprovalException } from './tools/toolShared' import { validatePathBasic, validatePathExists, validatePaths as validatePathsSync } from './utils/pathValidation' import { GrepSearch, SanitizedRipgrepOutput } from './tools/grepSearch' -import { FileSearch, FileSearchParams } from './tools/fileSearch' +import { FileSearch, FileSearchParams, isFileSearchParams } from './tools/fileSearch' import { FsReplace, FsReplaceParams } from './tools/fsReplace' import { loggingUtils, timeoutUtils } from '@aws/lsp-core' import { diffLines } from 'diff' @@ -228,6 +228,7 @@ import { UserContext } from '../../client/token/codewhispererbearertokenclient' import { CodeWhispererServiceToken } from '../../shared/codeWhispererService' import { DisplayFindings } from './tools/qCodeAnalysis/displayFindings' import { IDE } from '../../shared/constants' +import { IdleWorkspaceManager } from '../workspaceContext/IdleWorkspaceManager' type ChatHandlers = Omit< LspHandlers, @@ -828,6 +829,8 @@ export class AgenticChatController implements ChatHandlers { // Phase 1: Initial Setup - This happens only once params.prompt.prompt = sanitizeInput(params.prompt.prompt || '') + IdleWorkspaceManager.recordActivityTimestamp() + const maybeDefaultResponse = !params.prompt.command && getDefaultChatResponse(params.prompt.prompt) if (maybeDefaultResponse) { return maybeDefaultResponse @@ -1406,6 +1409,25 @@ export class AgenticChatController implements ChatHandlers { shouldDisplayMessage = false // set the in progress tool use UI status to Error await chatResultStream.updateOngoingProgressResult('Error') + + // emit invokeLLM event with status Failed for timeout calls + this.#telemetryController.emitAgencticLoop_InvokeLLM( + response.$metadata.requestId!, + conversationId, + 'AgenticChat', + undefined, + undefined, + 'Failed', + this.#features.runtime.serverInfo.version ?? '', + session.modelId, + llmLatency, + this.#toolCallLatencies, + this.#timeToFirstChunk, + this.#timeBetweenChunks, + session.pairProgrammingMode, + this.#abTestingAllocation?.experimentName, + this.#abTestingAllocation?.userVariation + ) continue } @@ -1451,7 +1473,7 @@ export class AgenticChatController implements ChatHandlers { 'AgenticChat', undefined, undefined, - 'Succeeded', + result.success ? 'Succeeded' : 'Failed', this.#features.runtime.serverInfo.version ?? '', session.modelId, llmLatency, @@ -1471,7 +1493,14 @@ export class AgenticChatController implements ChatHandlers { session.setConversationType('AgenticChatWithToolUse') if (result.success) { // Process tool uses and update the request input for the next iteration - toolResults = await this.#processToolUses(pendingToolUses, chatResultStream, session, tabId, token) + toolResults = await this.processToolUses( + pendingToolUses, + chatResultStream, + session, + tabId, + token, + additionalContext + ) if (toolResults.some(toolResult => this.#shouldSendBackErrorContent(toolResult))) { content = 'There was an error processing one or more tool uses. Try again, do not apologize.' shouldDisplayMessage = false @@ -1746,12 +1775,13 @@ export class AgenticChatController implements ChatHandlers { /** * Processes tool uses by running the tools and collecting results */ - async #processToolUses( + async processToolUses( toolUses: Array, chatResultStream: AgenticChatResultStream, session: ChatSessionService, tabId: string, - token?: CancellationToken + token?: CancellationToken, + additionalContext?: AdditionalContentEntryAddition[] ): Promise { const results: ToolResult[] = [] @@ -1779,8 +1809,7 @@ export class AgenticChatController implements ChatHandlers { // remove progress UI await chatResultStream.removeResultBlockAndUpdateUI(progressPrefix + toolUse.toolUseId) - // fsRead and listDirectory write to an existing card and could show nothing in the current position - if (![FS_WRITE, FS_REPLACE, FS_READ, LIST_DIRECTORY].includes(toolUse.name)) { + if (![FS_WRITE, FS_REPLACE].includes(toolUse.name)) { await this.#showUndoAllIfRequired(chatResultStream, session) } // fsWrite can take a long time, so we render fsWrite Explanatory upon partial streaming responses. @@ -1953,12 +1982,11 @@ export class AgenticChatController implements ChatHandlers { if (toolUse.name === CodeReview.toolName) { try { let initialInput = JSON.parse(JSON.stringify(toolUse.input)) - let ruleArtifacts = await this.#additionalContextProvider.collectWorkspaceRules(tabId) - if (ruleArtifacts !== undefined || ruleArtifacts !== null) { - this.#features.logging.info(`RuleArtifacts: ${JSON.stringify(ruleArtifacts)}`) - let pathsToRulesMap = ruleArtifacts.map(ruleArtifact => ({ path: ruleArtifact.id })) - this.#features.logging.info(`PathsToRules: ${JSON.stringify(pathsToRulesMap)}`) - initialInput['ruleArtifacts'] = pathsToRulesMap + + if (additionalContext !== undefined) { + initialInput['ruleArtifacts'] = additionalContext + .filter(c => c.type === 'rule') + .map(c => ({ path: c.path })) } toolUse.input = initialInput } catch (e) { @@ -1995,10 +2023,19 @@ export class AgenticChatController implements ChatHandlers { switch (toolUse.name) { case FS_READ: case LIST_DIRECTORY: + const readToolResult = await this.#processReadTool(toolUse, chatResultStream) + if (readToolResult) { + await chatResultStream.writeResultBlock(readToolResult) + } + break case FILE_SEARCH: - const initialListDirResult = this.#processReadOrListOrSearch(toolUse, chatResultStream) - if (initialListDirResult) { - await chatResultStream.writeResultBlock(initialListDirResult) + if (isFileSearchParams(toolUse.input)) { + await this.#processFileSearchTool( + toolUse.input, + toolUse.toolUseId, + result, + chatResultStream + ) } break // no need to write tool result for listDir,fsRead,fileSearch into chat stream @@ -2399,7 +2436,6 @@ export class AgenticChatController implements ChatHandlers { } const toolMsgId = toolUse.toolUseId! - const chatMsgId = chatResultStream.getResult().messageId let headerEmitted = false const initialHeader: ChatMessage['header'] = { @@ -2437,13 +2473,6 @@ export class AgenticChatController implements ChatHandlers { header: completedHeader, }) - await chatResultStream.writeResultBlock({ - type: 'answer', - messageId: chatMsgId, - body: '', - header: undefined, - }) - this.#stoppedToolUses.add(toolMsgId) }, }) @@ -2961,70 +2990,135 @@ export class AgenticChatController implements ChatHandlers { } } - #processReadOrListOrSearch(toolUse: ToolUse, chatResultStream: AgenticChatResultStream): ChatMessage | undefined { - let messageIdToUpdate = toolUse.toolUseId! - const currentId = chatResultStream.getMessageIdToUpdateForTool(toolUse.name!) + async #processFileSearchTool( + toolInput: FileSearchParams, + toolUseId: string, + result: InvokeOutput, + chatResultStream: AgenticChatResultStream + ): Promise { + if (typeof result.output.content !== 'string') return - if (currentId) { - messageIdToUpdate = currentId - } else { - chatResultStream.setMessageIdToUpdateForTool(toolUse.name!, messageIdToUpdate) + const { queryName, path: inputPath } = toolInput + const resultCount = result.output.content + .split('\n') + .filter(line => line.trim().startsWith('[F]') || line.trim().startsWith('[D]')).length + + const chatMessage: ChatMessage = { + type: 'tool', + messageId: toolUseId, + header: { + body: `Searched for "${queryName}" in `, + icon: 'search', + status: { + text: `${resultCount} result${resultCount !== 1 ? 's' : ''} found`, + }, + fileList: { + filePaths: [inputPath], + details: { + [inputPath]: { + description: inputPath, + visibleName: path.basename(inputPath), + clickable: false, + }, + }, + }, + }, } - let currentPaths = [] + await chatResultStream.writeResultBlock(chatMessage) + } + + async #processReadTool( + toolUse: ToolUse, + chatResultStream: AgenticChatResultStream + ): Promise { + let currentPaths: string[] = [] if (toolUse.name === FS_READ) { - currentPaths = (toolUse.input as unknown as FsReadParams)?.paths + currentPaths = (toolUse.input as unknown as FsReadParams)?.paths || [] + } else if (toolUse.name === LIST_DIRECTORY) { + const singlePath = (toolUse.input as unknown as ListDirectoryParams)?.path + if (singlePath) { + currentPaths = [singlePath] + } + } else if (toolUse.name === FILE_SEARCH) { + const queryName = (toolUse.input as unknown as FileSearchParams)?.queryName + if (queryName) { + currentPaths = [queryName] + } } else { - currentPaths.push((toolUse.input as unknown as ListDirectoryParams | FileSearchParams)?.path) + return } - if (!currentPaths) return + if (currentPaths.length === 0) return - for (const currentPath of currentPaths) { - const existingPaths = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths || [] - // Check if path already exists in the list - const isPathAlreadyProcessed = existingPaths.some(path => path.relativeFilePath === currentPath) - if (!isPathAlreadyProcessed) { - const currentFileDetail = { - relativeFilePath: currentPath, - lineRanges: [{ first: -1, second: -1 }], - } - chatResultStream.addMessageOperation(messageIdToUpdate, toolUse.name!, [ - ...existingPaths, - currentFileDetail, - ]) + // Check if the last message is the same tool type + const lastMessage = chatResultStream.getLastMessage() + const isSameToolType = + lastMessage?.type === 'tool' && lastMessage.header?.icon === this.#toolToIcon(toolUse.name) + + let allPaths = currentPaths + + if (isSameToolType && lastMessage.messageId) { + // Combine with existing paths and overwrite the last message + const existingPaths = lastMessage.header?.fileList?.filePaths || [] + allPaths = [...existingPaths, ...currentPaths] + + const blockId = chatResultStream.getMessageBlockId(lastMessage.messageId) + if (blockId !== undefined) { + // Create the updated message with combined paths + const updatedMessage = this.#createFileListToolMessage(toolUse, allPaths, lastMessage.messageId) + // Overwrite the existing block + await chatResultStream.overwriteResultBlock(updatedMessage, blockId) + return undefined // Don't return a message since we already wrote it } } + + // Create new message with current paths + return this.#createFileListToolMessage(toolUse, allPaths, toolUse.toolUseId!) + } + + #createFileListToolMessage(toolUse: ToolUse, filePaths: string[], messageId: string): ChatMessage { + const itemCount = filePaths.length let title: string - const itemCount = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths.length - const filePathsPushed = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths ?? [] - if (!itemCount) { + if (itemCount === 0) { title = 'Gathering context' } else { title = toolUse.name === FS_READ ? `${itemCount} file${itemCount > 1 ? 's' : ''} read` - : toolUse.name === FILE_SEARCH - ? `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} searched` - : `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed` + : toolUse.name === LIST_DIRECTORY + ? `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed` + : '' } const details: Record = {} - for (const item of filePathsPushed) { - details[item.relativeFilePath] = { - lineRanges: item.lineRanges, - description: item.relativeFilePath, + for (const filePath of filePaths) { + details[filePath] = { + description: filePath, + visibleName: path.basename(filePath), + clickable: toolUse.name === FS_READ, } } - - const fileList: FileList = { - rootFolderTitle: title, - filePaths: filePathsPushed.map(item => item.relativeFilePath), - details, - } return { type: 'tool', - fileList, - messageId: messageIdToUpdate, - body: '', + header: { + body: title, + icon: this.#toolToIcon(toolUse.name), + fileList: { + filePaths, + details, + }, + }, + messageId, + } + } + + #toolToIcon(toolName: string | undefined): string | undefined { + switch (toolName) { + case FS_READ: + return 'eye' + case LIST_DIRECTORY: + return 'check-list' + default: + return undefined } } @@ -3040,14 +3134,7 @@ export class AgenticChatController implements ChatHandlers { return undefined } - let messageIdToUpdate = toolUse.toolUseId! - const currentId = chatResultStream.getMessageIdToUpdateForTool(toolUse.name!) - - if (currentId) { - messageIdToUpdate = currentId - } else { - chatResultStream.setMessageIdToUpdateForTool(toolUse.name!, messageIdToUpdate) - } + const messageIdToUpdate = toolUse.toolUseId! // Extract search results from the tool output const output = result.output.content as SanitizedRipgrepOutput @@ -3372,6 +3459,9 @@ export class AgenticChatController implements ChatHandlers { const metric = new Metric({ cwsprChatConversationType: 'Chat', }) + + IdleWorkspaceManager.recordActivityTimestamp() + const triggerContext = await this.#getInlineChatTriggerContext(params) let response: ChatCommandOutput diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatResultStream.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatResultStream.ts index 70b3452361..195e1e880b 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatResultStream.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatResultStream.ts @@ -1,4 +1,4 @@ -import { ChatResult, FileDetails, ChatMessage } from '@aws/language-server-runtimes/protocol' +import { ChatResult, ChatMessage } from '@aws/language-server-runtimes/protocol' import { randomUUID } from 'crypto' export interface ResultStreamWriter { @@ -32,33 +32,20 @@ export interface ResultStreamWriter { close(): Promise } +export const progressPrefix = 'progress_' + /** * This class wraps around lsp.sendProgress to provide a more helpful interface for streaming a ChatResult to the client. * ChatResults are grouped into blocks that can be written directly, or streamed in. * In the final message, blocks are seperated by resultDelimiter defined below. */ - -interface FileDetailsWithPath extends FileDetails { - relativeFilePath: string -} - -type OperationType = 'read' | 'write' | 'listDir' - -export const progressPrefix = 'progress_' - -interface FileOperation { - type: OperationType - filePaths: FileDetailsWithPath[] -} export class AgenticChatResultStream { - static readonly resultDelimiter = '\n\n' + static readonly resultDelimiter = '\n' #state = { chatResultBlocks: [] as ChatMessage[], isLocked: false, uuid: randomUUID(), messageId: undefined as string | undefined, - messageIdToUpdateForTool: new Map(), - messageOperations: new Map(), } readonly #sendProgress: (newChatResult: ChatResult | string) => Promise @@ -70,33 +57,6 @@ export class AgenticChatResultStream { return this.#joinResults(this.#state.chatResultBlocks, only) } - setMessageIdToUpdateForTool(toolName: string, messageId: string) { - this.#state.messageIdToUpdateForTool.set(toolName as OperationType, messageId) - } - - getMessageIdToUpdateForTool(toolName: string): string | undefined { - return this.#state.messageIdToUpdateForTool.get(toolName as OperationType) - } - - /** - * Adds a file operation for a specific message - * @param messageId The ID of the message - * @param type The type of operation ('fsRead' or 'listDirectory' or 'fsWrite') - * @param filePaths Array of FileDetailsWithPath involved in the operation - */ - addMessageOperation(messageId: string, type: string, filePaths: FileDetailsWithPath[]) { - this.#state.messageOperations.set(messageId, { type: type as OperationType, filePaths }) - } - - /** - * Gets the file operation details for a specific message - * @param messageId The ID of the message - * @returns The file operation details or undefined if not found - */ - getMessageOperation(messageId: string): FileOperation | undefined { - return this.#state.messageOperations.get(messageId) - } - #joinResults(chatResults: ChatMessage[], only?: string): ChatResult { const result: ChatResult = { body: '', @@ -111,9 +71,9 @@ export class AgenticChatResultStream { return { ...acc, buttons: [...(acc.buttons ?? []), ...(c.buttons ?? [])], - body: acc.body + AgenticChatResultStream.resultDelimiter + c.body, - ...(c.contextList && { contextList: c.contextList }), - header: Object.prototype.hasOwnProperty.call(c, 'header') ? c.header : acc.header, + body: acc.body + (c.body ? AgenticChatResultStream.resultDelimiter + c.body : ''), + ...(c.contextList && c.type !== 'tool' && { contextList: c.contextList }), + header: c.header !== undefined ? c.header : acc.header, codeReference: [...(acc.codeReference ?? []), ...(c.codeReference ?? [])], } } else if (acc.additionalMessages!.some(am => am.messageId === c.messageId)) { @@ -127,9 +87,10 @@ export class AgenticChatResultStream { : am.buttons, body: am.messageId === c.messageId - ? am.body + AgenticChatResultStream.resultDelimiter + c.body + ? am.body + (c.body ? AgenticChatResultStream.resultDelimiter + c.body : '') : am.body, ...(am.messageId === c.messageId && + c.type !== 'tool' && (c.contextList || acc.contextList) && { contextList: { filePaths: [ @@ -161,7 +122,7 @@ export class AgenticChatResultStream { }, }, }), - header: Object.prototype.hasOwnProperty.call(c, 'header') ? c.header : am.header, + ...(am.messageId === c.messageId && c.header !== undefined && { header: c.header }), })), } } else { @@ -246,6 +207,10 @@ export class AgenticChatResultStream { return undefined } + getLastMessage(): ChatMessage { + return this.#state.chatResultBlocks[this.#state.chatResultBlocks.length - 1] + } + getResultStreamWriter(): ResultStreamWriter { // Note: if write calls are not awaited, stream can be out-of-order. if (this.#state.isLocked) { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts index 09fbb20436..1729c10c1d 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts @@ -9,6 +9,8 @@ export const RESPONSE_TIMEOUT_PARTIAL_MSG = 'Response processing timed out after export const LOADING_THRESHOLD_MS = 2000 export const CLIENT_TIMEOUT_MS = 245_000 export const RESPONSE_TIMEOUT_MS = 240_000 +export const SERVICE_MANAGER_TIMEOUT_MS = 10_000 //10 seconds +export const SERVICE_MANAGER_POLL_INTERVAL_MS = 100 // LLM Constants export const GENERATE_ASSISTANT_RESPONSE_INPUT_LIMIT = 500_000 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 6c745f20b3..e6742fee1b 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 @@ -174,6 +174,12 @@ describe('AdditionalContextProvider', () => { workspaceFolder: mockWorkspaceFolder, } + // Mock path.join to simulate Unix behavior + sinon.stub(path, 'join').callsFake((...args) => { + // Simulate Unix path.join behavior + return args.join('/').replace(/\\/g, '/') + }) + const explicitContext = [ { id: 'explicit-file', @@ -208,6 +214,9 @@ describe('AdditionalContextProvider', () => { assert.strictEqual(result.length, 1) assert.strictEqual(result[0].name, 'Explicit File') assert.strictEqual(result[0].pinned, false) + + // Restore original path.join + ;(path.join as sinon.SinonStub).restore() }) it('should avoid duplicates between explicit and pinned context', async () => { @@ -220,6 +229,12 @@ describe('AdditionalContextProvider', () => { workspaceFolder: mockWorkspaceFolder, } + // Mock path.join to simulate Unix behavior + sinon.stub(path, 'join').callsFake((...args) => { + // Simulate Unix path.join behavior + return args.join('/').replace(/\\/g, '/') + }) + const sharedContext = { id: 'shared-file', command: 'Shared File', @@ -255,6 +270,9 @@ describe('AdditionalContextProvider', () => { assert.strictEqual(result.length, 1) assert.strictEqual(result[0].name, 'Shared File') assert.strictEqual(result[0].pinned, false) // Should be marked as explicit, not pinned + + // Restore original path.join + ;(path.join as sinon.SinonStub).restore() }) it('should handle Active File context correctly', async () => { @@ -358,6 +376,105 @@ describe('AdditionalContextProvider', () => { assert.strictEqual(triggerContext.contextInfo?.pinnedContextCount.codeContextCount, 1) assert.strictEqual(triggerContext.contextInfo?.pinnedContextCount.promptContextCount, 1) }) + + it('should handle Unix path separators correctly', async () => { + const mockWorkspaceFolder = { uri: URI.file('/workspace').toString(), name: 'test' } + sinon.stub(workspaceUtils, 'getWorkspaceFolderPaths').returns(['/workspace']) + + // Mock path.join to simulate Unix behavior + sinon.stub(path, 'join').callsFake((...args) => { + // Simulate Unix path.join behavior + return args.join('/').replace(/\\/g, '/') + }) + + const explicitContext = [ + { + id: 'unix-prompt', + command: 'Unix Prompt', + label: 'file' as any, + route: ['/Users/test/.aws/amazonq/prompts', 'hello.md'], + }, + ] + + fsExistsStub.callsFake((path: string) => path.includes('.amazonq/rules')) + fsReadDirStub.resolves([]) + + // Reset stub - return data for first call (explicit context), empty for second call (pinned context) + getContextCommandPromptStub.reset() + getContextCommandPromptStub.onFirstCall().resolves([ + { + // promptContextCommands - explicit context + name: 'Unix Prompt', + content: 'content', + filePath: '/Users/test/.aws/amazonq/prompts/hello.md', // Proper Unix path + relativePath: 'hello.md', + startLine: 1, + endLine: 10, + }, + ]) + getContextCommandPromptStub.onSecondCall().resolves([]) // pinnedContextCommands - empty + + const result = await provider.getAdditionalContext( + { workspaceFolder: mockWorkspaceFolder }, + 'tab1', + explicitContext + ) + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].name, 'Unix Prompt') + + // Restore original path.join + ;(path.join as sinon.SinonStub).restore() + }) + + it('should handle Windows path separators correctly', async () => { + const mockWorkspaceFolder = { uri: URI.file('/workspace').toString(), name: 'test' } + sinon.stub(workspaceUtils, 'getWorkspaceFolderPaths').returns(['/workspace']) + + // Mock path.join to simulate Windows behavior + const originalPathJoin = path.join + sinon.stub(path, 'join').callsFake((...args) => { + // Simulate Windows path.join behavior + return args.join('\\').replace(/\//g, '\\') + }) + + const explicitContext = [ + { + id: 'windows-prompt', + command: 'Windows Prompt', + label: 'file' as any, + route: ['C:\\Users\\test\\.aws\\amazonq\\prompts', 'hello.md'], + }, + ] + + fsExistsStub.callsFake((path: string) => path.includes('.amazonq/rules')) + fsReadDirStub.resolves([]) + + // Reset stub - return data for first call (explicit context), empty for second call (pinned context) + getContextCommandPromptStub.reset() + getContextCommandPromptStub.onFirstCall().resolves([ + { + // promptContextCommands - explicit context + name: 'Windows Prompt', + content: 'content', + filePath: 'C:\\Users\\test\\.aws\\amazonq\\prompts\\hello.md', // Proper Windows path + relativePath: 'hello.md', + startLine: 1, + endLine: 10, + }, + ]) + getContextCommandPromptStub.onSecondCall().resolves([]) // pinnedContextCommands - empty + + const result = await provider.getAdditionalContext( + { workspaceFolder: mockWorkspaceFolder }, + 'tab1', + explicitContext + ) + assert.strictEqual(result.length, 1) + assert.strictEqual(result[0].name, 'Windows Prompt') + + // Restore original path.join + ;(path.join as sinon.SinonStub).restore() + }) }) describe('getFileListFromContext', () => { 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 d71da1a1fe..733d1df28c 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 @@ -406,7 +406,7 @@ export class AdditionalContextProvider { const image = imageMap.get(item.description) if (image) ordered.push(image) } else { - const doc = item.route ? docMap.get(item.route.join('/')) : undefined + const doc = item.route ? docMap.get(path.join(...item.route)) : undefined if (doc) ordered.push(doc) } } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/errors.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/errors.ts index 3038269463..819211dfab 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/errors.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/errors.ts @@ -11,6 +11,7 @@ type AgenticChatErrorCode = | 'MCPServerInitTimeout' // mcp server failed to start within allowed time | 'MCPToolExecTimeout' // mcp tool call failed to complete within allowed time | 'MCPServerConnectionFailed' // mcp server failed to connect + | 'MCPServerAuthFailed' // mcp server failed to complete auth flow | 'RequestAborted' // request was aborted by the user | 'RequestThrottled' // request was aborted by the user diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/qAgenticChatServer.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/qAgenticChatServer.ts index 205e5aff69..445ca78d85 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/qAgenticChatServer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/qAgenticChatServer.ts @@ -110,7 +110,7 @@ export const QAgenticChatServer = ) ) - const userContext = makeUserContextObject(clientParams, runtime.platform, 'CHAT') + const userContext = makeUserContextObject(clientParams, runtime.platform, 'CHAT', amazonQServiceManager.serverInfo) telemetryService.updateUserContext(userContext) chatController = new AgenticChatController( diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts index fb6486996e..37d11afe4f 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts @@ -158,3 +158,7 @@ export class FileSearch { } as const } } + +export function isFileSearchParams(input: any): input is FileSearchParams { + return input && typeof input.path === 'string' && typeof input.queryName === 'string' +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts index d8511d955d..cb645eec43 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts @@ -139,6 +139,7 @@ describe('McpEventHandler error handling', () => { command: '', // Invalid - missing command args: [], env: {}, + disabled: false, __configPath__: 'config.json', }, ], @@ -239,4 +240,109 @@ describe('McpEventHandler error handling', () => { expect(result.header).to.not.be.undefined expect(result.header.title).to.equal('Edit MCP Server') }) + + describe('#getListMcpServersStatus', () => { + beforeEach(() => { + sinon.restore() + sinon.stub(mcpUtils, 'getGlobalAgentConfigPath').returns('/fake/home/.aws/amazonq/agents/default.json') + saveAgentConfigStub = sinon.stub(mcpUtils, 'saveAgentConfig').resolves() + }) + + it('returns admin disabled status when MCP state is false', async () => { + // Stub ProfileStatusMonitor.getMcpState to return false + const { ProfileStatusMonitor } = await import('./profileStatusMonitor') + sinon.stub(ProfileStatusMonitor, 'getMcpState').returns(false) + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map(), + serverNameMapping: new Map(), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: {}, + tools: [], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + + await McpManager.init([], features) + const result = await eventHandler.onListMcpServers({}) + + expect(result.header.status).to.deep.equal({ + title: 'MCP functionality has been disabled by your administrator', + icon: 'info', + status: 'info', + }) + }) + + it('returns config error status when MCP state is not false but config errors exist', async () => { + // Stub ProfileStatusMonitor.getMcpState to return true + const { ProfileStatusMonitor } = await import('./profileStatusMonitor') + sinon.stub(ProfileStatusMonitor, 'getMcpState').returns(true) + + const mockErrors = new Map([['file1.json', 'Config error']]) + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map(), + serverNameMapping: new Map(), + errors: mockErrors, + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: {}, + tools: [], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + + await McpManager.init([], features) + sinon.stub(McpManager.instance, 'getConfigLoadErrors').returns('File: file1.json, Error: Config error') + + const result = await eventHandler.onListMcpServers({}) + + expect(result.header.status).to.deep.equal({ + title: 'File: file1.json, Error: Config error', + icon: 'cancel-circle', + status: 'error', + }) + }) + + it('returns undefined status when MCP state is not false and no config errors', async () => { + // Stub ProfileStatusMonitor.getMcpState to return true + const { ProfileStatusMonitor } = await import('./profileStatusMonitor') + sinon.stub(ProfileStatusMonitor, 'getMcpState').returns(true) + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map(), + serverNameMapping: new Map(), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: {}, + tools: [], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + + await McpManager.init([], features) + sinon.stub(McpManager.instance, 'getConfigLoadErrors').returns(undefined) + + const result = await eventHandler.onListMcpServers({}) + + expect(result.header.status).to.be.undefined + }) + }) }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts index 22befec0cd..d167299106 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts @@ -3,7 +3,6 @@ import { MCP_SERVER_STATUS_CHANGED, McpManager } from './mcpManager' import { ChatTelemetryController } from '../../../chat/telemetry/chatTelemetryController' import { ChokidarFileWatcher } from './chokidarFileWatcher' // eslint-disable-next-line import/no-nodejs-modules -import * as path from 'path' import { DetailedListGroup, DetailedListItem, @@ -13,14 +12,7 @@ import { Status, } from '@aws/language-server-runtimes/protocol' -import { - getGlobalMcpConfigPath, - getGlobalAgentConfigPath, - getWorkspaceMcpConfigPaths, - getWorkspaceAgentConfigPaths, - sanitizeName, - normalizePathFromUri, -} from './mcpUtils' +import { getGlobalAgentConfigPath, getWorkspaceAgentConfigPaths, sanitizeName, normalizePathFromUri } from './mcpUtils' import { McpPermissionType, MCPServerConfig, @@ -29,14 +21,21 @@ import { McpServerStatus, } from './mcpTypes' import { TelemetryService } from '../../../../shared/telemetry/telemetryService' -import { URI } from 'vscode-uri' +import { ProfileStatusMonitor } from './profileStatusMonitor' interface PermissionOption { label: string value: string + description?: string +} + +enum TransportType { + STDIO = 'stdio', + HTTP = 'http', } export class McpEventHandler { + private static readonly FILE_WATCH_DEBOUNCE_MS = 2000 #features: Features #eventListenerRegistered: boolean #currentEditingServerName: string | undefined @@ -50,6 +49,12 @@ export class McpEventHandler { #lastProgrammaticState: boolean = false #serverNameBeforeUpdate: string | undefined + #releaseProgrammaticAfterDebounce(padMs = 500) { + setTimeout(() => { + this.#isProgrammaticChange = false + }, McpEventHandler.FILE_WATCH_DEBOUNCE_MS + padMs) + } + constructor(features: Features, telemetryService: TelemetryService) { this.#features = features this.#eventListenerRegistered = false @@ -188,13 +193,14 @@ export class McpEventHandler { ], } - // if (mcpManager.isServerDisabled(serverName)) { - // disabledItems.push(item) - // } else { - activeItems.push({ - ...item, - description: `${toolsCount}`, - }) + if (mcpManager.isServerDisabled(serverName)) { + disabledItems.push(item) + } else { + activeItems.push({ + ...item, + description: `${toolsCount}`, + }) + } }) // Create the groups @@ -221,18 +227,63 @@ export class McpEventHandler { } // Return the result in the expected format + const mcpState = ProfileStatusMonitor.getMcpState() const header = { title: 'MCP Servers', - description: "Add MCP servers to extend Q's capabilities.", - // only show error on list mcp server page if unable to read mcp.json file - status: configLoadErrors - ? { title: configLoadErrors, icon: 'cancel-circle', status: 'error' as Status } - : undefined, + description: mcpState === false ? '' : "Add MCP servers to extend Q's capabilities.", + status: this.#getListMcpServersStatus(configLoadErrors, mcpState), + actions: this.#getListMcpServersActions(configLoadErrors, mcpState), } return { header, list: groups } } + /** + * Gets the status for the list MCP servers header + */ + #getListMcpServersStatus( + configLoadErrors: string | undefined, + mcpState: boolean | undefined + ): { title: string; icon: string; status: Status } | undefined { + if (mcpState === false) { + return { + title: 'MCP functionality has been disabled by your administrator', + icon: 'info', + status: 'info' as Status, + } + } + + if (configLoadErrors) { + return { title: configLoadErrors, icon: 'cancel-circle', status: 'error' as Status } + } + + return undefined + } + + /** + * Gets the actions for the list MCP servers header + */ + #getListMcpServersActions(configLoadErrors: string | undefined, mcpState: boolean | undefined) { + return mcpState !== false && (!configLoadErrors || configLoadErrors === '') + ? [ + { + id: 'add-new-mcp', + icon: 'plus', + status: 'clear', + text: 'Add new MCP server', + description: 'Add new MCP server', + }, + { + id: 'refresh-mcp-list', + icon: 'refresh', + status: 'clear', + text: 'Refresh MCP servers', + description: 'Refresh MCP servers', + }, + ] + : [] + } + /** * Handles MCP server click events */ @@ -345,7 +396,7 @@ export class McpEventHandler { const serverStatusError = this.#getServerStatusError(existingValues.name) || {} // Determine which transport is selected (default to stdio) - const selectedTransport = existingValues.transport || 'stdio' + const selectedTransport = existingValues.transport || TransportType.STDIO return { id: params.id, @@ -384,14 +435,14 @@ export class McpEventHandler { title: 'Transport', mandatory: true, options: [ - { label: 'stdio', value: 'stdio' }, - { label: 'http', value: 'http' }, + { label: TransportType.STDIO, value: TransportType.STDIO }, + { label: TransportType.HTTP, value: TransportType.HTTP }, ], value: selectedTransport, }, ] - if (selectedTransport === 'http') { + if (selectedTransport === TransportType.HTTP) { return [ ...common, { @@ -555,8 +606,13 @@ export class McpEventHandler { errors.push('Either command or url is required') } else if (command && url) { errors.push('Provide either command OR url, not both') - } else if (transport && ((transport === 'stdio' && !command) || (transport !== 'stdio' && !url))) { - errors.push(`${transport === 'stdio' ? 'Command' : 'URL'} is required for ${transport} transport`) + } else if ( + transport && + ((transport === TransportType.STDIO && !command) || (transport !== TransportType.STDIO && !url)) + ) { + errors.push( + `${transport === TransportType.STDIO ? 'Command' : 'URL'} is required for ${transport} transport` + ) } if (values.timeout && values.timeout.trim() !== '') { @@ -644,7 +700,7 @@ export class McpEventHandler { // stdio‑specific parsing let args: string[] = [] let env: Record = {} - if (selectedTransport === 'stdio') { + if (selectedTransport === TransportType.STDIO) { try { args = (Array.isArray(params.optionsValues.args) ? params.optionsValues.args : []) .map((item: any) => @@ -671,7 +727,7 @@ export class McpEventHandler { // http‑specific parsing let headers: Record = {} - if (selectedTransport === 'http') { + if (selectedTransport === TransportType.HTTP) { try { const raw = Array.isArray(params.optionsValues.headers) ? params.optionsValues.headers : [] headers = raw.reduce((acc: Record, item: any) => { @@ -695,7 +751,7 @@ export class McpEventHandler { // build final config (no transport field persisted) let config: MCPServerConfig - if (selectedTransport === 'http') { + if (selectedTransport === TransportType.HTTP) { config = { url: params.optionsValues.url, headers, @@ -738,16 +794,17 @@ export class McpEventHandler { } this.#currentEditingServerName = undefined + this.#serverNameBeforeUpdate = undefined // need to check server state now, as there is possibility of error during server initialization const serverStatusError = this.#getServerStatusError(serverName) this.#telemetryController?.emitMCPServerInitializeEvent({ source: isEditMode ? 'updateServer' : 'addServer', - command: selectedTransport === 'stdio' ? params.optionsValues.command : undefined, - url: selectedTransport === 'http' ? params.optionsValues.url : undefined, + command: selectedTransport === TransportType.STDIO ? params.optionsValues.command : undefined, + url: selectedTransport === TransportType.HTTP ? params.optionsValues.url : undefined, enabled: true, - numTools: McpManager.instance.getAllToolsWithPermissions(serverName).length, + numTools: McpManager.instance.getAllToolsWithPermissions(sanitizedServerName).length, scope: params.optionsValues['scope'] === 'global' ? 'global' : 'workspace', transportType: selectedTransport, languageServerVersion: this.#features.runtime.serverInfo.version, @@ -762,6 +819,7 @@ export class McpEventHandler { // Stay on add/edit page and show error to user // Keep isProgrammaticChange true during error handling to prevent file watcher triggers + this.#releaseProgrammaticAfterDebounce() if (isEditMode) { params.id = 'edit-mcp' params.title = sanitizedServerName @@ -776,7 +834,7 @@ export class McpEventHandler { this.#newlyAddedServers.delete(serverName) } - this.#isProgrammaticChange = false + this.#releaseProgrammaticAfterDebounce() // Go to tools permissions page return this.#handleOpenMcpServer({ id: 'open-mcp-server', title: sanitizedServerName }) @@ -865,25 +923,23 @@ export class McpEventHandler { return { id: params.id } } + const mcpManager = McpManager.instance // Get the appropriate agent path - const agentPath = await this.#getAgentPath() - - const perm: MCPServerPermission = { - enabled: true, - toolPerms: {}, - __configPath__: agentPath, - } - + const agentPath = mcpManager.getAllServerConfigs().get(serverName)?.__configPath__ // Set flag to ignore file changes during permission update this.#isProgrammaticChange = true try { - await McpManager.instance.updateServerPermission(serverName, perm) + const perm = mcpManager.getMcpServerPermissions(serverName)! + perm.enabled = true + perm.__configPath__ = agentPath + await mcpManager.updateServerPermission(serverName, perm) this.#emitMCPConfigEvent() + this.#releaseProgrammaticAfterDebounce() } catch (error) { this.#features.logging.error(`Failed to enable MCP server: ${error}`) + this.#releaseProgrammaticAfterDebounce() } - this.#isProgrammaticChange = false return { id: params.id } } @@ -895,27 +951,23 @@ export class McpEventHandler { if (!serverName) { return { id: params.id } } - - // Get the appropriate agent path - const agentPath = await this.#getAgentPath() - - const perm: MCPServerPermission = { - enabled: false, - toolPerms: {}, - __configPath__: agentPath, - } - + const mcpManager = McpManager.instance + // Set flag to ignore file changes during permission update + const agentPath = mcpManager.getAllServerConfigs().get(serverName)?.__configPath__ // Set flag to ignore file changes during permission update this.#isProgrammaticChange = true - try { - await McpManager.instance.updateServerPermission(serverName, perm) + const perm = mcpManager.getMcpServerPermissions(serverName)! + perm.enabled = false + perm.__configPath__ = agentPath + await mcpManager.updateServerPermission(serverName, perm) this.#emitMCPConfigEvent() + this.#releaseProgrammaticAfterDebounce() } catch (error) { this.#features.logging.error(`Failed to disable MCP server: ${error}`) + this.#releaseProgrammaticAfterDebounce() } - this.#isProgrammaticChange = false return { id: params.id } } @@ -933,11 +985,11 @@ export class McpEventHandler { try { await McpManager.instance.removeServer(serverName) - + this.#releaseProgrammaticAfterDebounce() return { id: params.id } } catch (error) { this.#features.logging.error(`Failed to delete MCP server: ${error}`) - this.#isProgrammaticChange = false + this.#releaseProgrammaticAfterDebounce() return { id: params.id } } } @@ -974,7 +1026,7 @@ export class McpEventHandler { } // Respect a user flip first; otherwise fall back to what the stored configuration implies. - const transport = params.optionsValues?.transport ?? (config.url ? 'http' : 'stdio') + const transport = params.optionsValues?.transport ?? (config.url ? TransportType.HTTP : TransportType.STDIO) // Convert stored structures to UI‑friendly lists const argsList = (config.args ?? []).map(a => ({ arg_key: a })) // for stdio @@ -1028,17 +1080,16 @@ export class McpEventHandler { // Add tool select options toolsWithPermissions.forEach(item => { const toolName = item.tool.toolName - const currentPermission = this.#getCurrentPermission(item.permission) // For Built-in server, use a special function that doesn't include the 'Deny' option - const permissionOptions = this.#buildPermissionOptions(item.permission) + let permissionOptions = this.#buildPermissionOptions() filterOptions.push({ type: 'select', id: `${toolName}`, title: toolName, description: item.tool.description, - placeholder: currentPermission, options: permissionOptions, + ...{ value: item.permission, boldTitle: true, mandatory: true, hideMandatoryIcon: true }, }) }) @@ -1051,8 +1102,9 @@ export class McpEventHandler { // Clean up transport-specific fields if (optionsValues) { - const transport = optionsValues.transport ?? 'stdio' // Maintain default to 'stdio' - const fieldsToDelete = transport === 'http' ? ['command', 'args', 'env_variables'] : ['url', 'headers'] + const transport = optionsValues.transport ?? TransportType.STDIO // Maintain default to 'stdio' + const fieldsToDelete = + transport === TransportType.HTTP ? ['command', 'args', 'env_variables'] : ['url', 'headers'] fieldsToDelete.forEach(field => delete optionsValues[field]) } @@ -1091,20 +1143,22 @@ export class McpEventHandler { /** * Builds permission options excluding the current one */ - #buildPermissionOptions(currentPermission: string) { + #buildPermissionOptions() { const permissionOptions: PermissionOption[] = [] - if (currentPermission !== McpPermissionType.alwaysAllow) { - permissionOptions.push({ label: 'Always allow', value: McpPermissionType.alwaysAllow }) - } + permissionOptions.push({ + label: 'Ask', + value: McpPermissionType.ask, + description: 'Ask for your approval each time this tool is run', + }) - if (currentPermission !== McpPermissionType.ask) { - permissionOptions.push({ label: 'Ask', value: McpPermissionType.ask }) - } + permissionOptions.push({ + label: 'Always allow', + value: McpPermissionType.alwaysAllow, + description: 'Always allow this tool to run without asking for approval', + }) - if (currentPermission !== McpPermissionType.deny) { - permissionOptions.push({ label: 'Deny', value: McpPermissionType.deny }) - } + permissionOptions.push({ label: 'Deny', value: McpPermissionType.deny, description: 'Never run this tool' }) return permissionOptions } @@ -1153,6 +1207,7 @@ export class McpEventHandler { } const mcpServerPermission = await this.#processPermissionUpdates( + serverName, updatedPermissionConfig, serverConfig?.__configPath__ ) @@ -1196,11 +1251,11 @@ export class McpEventHandler { const serverConfig = McpManager.instance.getAllServerConfigs().get(serverName) if (serverConfig) { // Emit server initialize event after permission change - const transportType = serverConfig.command ? 'stdio' : 'http' + const transportType = serverConfig.command?.trim() ? TransportType.STDIO : TransportType.HTTP this.#telemetryController?.emitMCPServerInitializeEvent({ source: 'updatePermission', - command: transportType === 'stdio' ? serverConfig.command : undefined, - url: transportType === 'http' ? serverConfig.url : undefined, + command: transportType === TransportType.STDIO ? serverConfig.command : undefined, + url: transportType === TransportType.HTTP ? serverConfig.url : undefined, enabled: true, numTools: McpManager.instance.getAllToolsWithPermissions(serverName).length, scope: @@ -1217,10 +1272,11 @@ export class McpEventHandler { this.#pendingPermissionConfig = undefined this.#features.logging.info(`Applied permission changes for server: ${serverName}`) + this.#releaseProgrammaticAfterDebounce() return { id: params.id } } catch (error) { this.#features.logging.error(`Failed to save MCP permissions: ${error}`) - this.#isProgrammaticChange = false + this.#releaseProgrammaticAfterDebounce() return { id: params.id } } } @@ -1229,7 +1285,9 @@ export class McpEventHandler { // Emit MCP config event after reinitialization const mcpManager = McpManager.instance const serverConfigs = mcpManager.getAllServerConfigs() - const activeServers = Array.from(serverConfigs.entries()) + const activeServers = Array.from(serverConfigs.entries()).filter( + ([name, _]) => !mcpManager.isServerDisabled(name) + ) // Get the global agent path const globalAgentPath = getGlobalAgentConfigPath(this.#features.workspace.fs.getUserHomeDir()) @@ -1266,16 +1324,16 @@ export class McpEventHandler { // Emit server initialize events for all active servers for (const [serverName, config] of serverConfigs.entries()) { - const transportType = config.command ? 'stdio' : 'http' - // const enabled = !mcpManager.isServerDisabled(serverName) + const transportType = config.command ? TransportType.STDIO : TransportType.HTTP + const enabled = !mcpManager.isServerDisabled(serverName) this.#telemetryController?.emitMCPServerInitializeEvent({ source: 'reload', - command: transportType === 'stdio' ? config.command : undefined, - url: transportType === 'http' ? config.url : undefined, - enabled: true, + command: transportType === TransportType.STDIO ? config.command : undefined, + url: transportType === TransportType.HTTP ? config.url : undefined, + enabled: enabled, numTools: mcpManager.getAllToolsWithPermissions(serverName).length, scope: config.__configPath__ === globalAgentPath ? 'global' : 'workspace', - transportType: 'stdio', + transportType: transportType, languageServerVersion: this.#features.runtime.serverInfo.version, }) } @@ -1319,12 +1377,10 @@ export class McpEventHandler { * @returns The agent path to use (workspace if exists, otherwise global) */ async #getAgentPath(isGlobal: boolean = true): Promise { + const globalAgentPath = getGlobalAgentConfigPath(this.#features.workspace.fs.getUserHomeDir()) if (isGlobal) { - return getGlobalAgentConfigPath(this.#features.workspace.fs.getUserHomeDir()) + return globalAgentPath } - - const globalAgentPath = getGlobalAgentConfigPath(this.#features.workspace.fs.getUserHomeDir()) - // Get workspace folders and check for workspace agent path const workspaceFolders = this.#features.workspace.getAllWorkspaceFolders() if (workspaceFolders && workspaceFolders.length > 0) { @@ -1350,27 +1406,20 @@ export class McpEventHandler { /** * Processes permission updates from the UI */ - async #processPermissionUpdates(updatedPermissionConfig: any, agentPath: string | undefined) { + async #processPermissionUpdates(serverName: string, updatedPermissionConfig: any, agentPath: string | undefined) { + const builtInToolAgentPath = await this.#getAgentPath() const perm: MCPServerPermission = { enabled: true, toolPerms: {}, - __configPath__: agentPath, + __configPath__: serverName === 'Built-in' ? builtInToolAgentPath : agentPath, } // Process each tool permission setting for (const [key, val] of Object.entries(updatedPermissionConfig)) { if (key === 'scope') continue - // // Get the default permission for this tool from McpManager - // let defaultPermission = McpManager.instance.getToolPerm(serverName, key) - - // // If no default permission is found, use 'alwaysAllow' for Built-in and 'ask' for MCP servers - // if (!defaultPermission) { - // defaultPermission = serverName === 'Built-in' ? 'alwaysAllow' : 'ask' - // } - - // If the value is an empty string (''), skip this tool to preserve its existing permission in the persona file - if (val === '') continue + const currentPerm = McpManager.instance.getToolPerm(serverName, key) + if (val === currentPerm) continue switch (val) { case McpPermissionType.alwaysAllow: perm.toolPerms[key] = McpPermissionType.alwaysAllow @@ -1392,7 +1441,8 @@ export class McpEventHandler { */ #getServerStatusError(serverName: string): { title: string; icon: string; status: Status } | undefined { const serverStates = McpManager.instance.getAllServerStates() - const serverState = serverStates.get(serverName) + const key = serverName ? sanitizeName(serverName) : serverName + const serverState = key ? serverStates.get(key) : undefined if (!serverState) { return undefined @@ -1456,11 +1506,10 @@ export class McpEventHandler { if (!this.#lastProgrammaticState) { await this.#handleRefreshMCPList({ id: 'refresh-mcp-list' }) } else { - this.#isProgrammaticChange = false this.#features.logging.debug('Skipping refresh due to programmatic change') } this.#debounceTimer = null - }, 2000) + }, McpEventHandler.FILE_WATCH_DEBOUNCE_MS) }) } 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 0f9e33bf26..0418cea553 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 @@ -113,6 +113,7 @@ describe('callTool()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'p.json', } @@ -140,6 +141,42 @@ describe('callTool()', () => { } }) + it('throws when server is disabled', async () => { + const disabledCfg: MCPServerConfig = { + command: 'c', + args: [], + env: {}, + timeout: 0, + disabled: true, + __configPath__: 'p.json', + } + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map([['s1', disabledCfg]]), + serverNameMapping: new Map(), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: { s1: disabledCfg }, + tools: ['@s1'], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + const mgr = await McpManager.init(['p.json'], features) + + try { + await mgr.callTool('s1', 'tool1', {}) + throw new Error('should have thrown') + } catch (err: any) { + expect(err.message).to.equal("MCP: server 's1' is disabled") + } + }) + it('invokes underlying client.callTool', async () => { loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ servers: new Map([['s1', enabledCfg]]), @@ -202,12 +239,12 @@ describe('callTool()', () => { describe('addServer()', () => { let loadStub: sinon.SinonStub let initOneStub: sinon.SinonStub - let saveAgentConfigStub: sinon.SinonStub + let saveServerSpecificAgentConfigStub: sinon.SinonStub beforeEach(() => { loadStub = stubAgentConfig() initOneStub = stubInitOneServer() - saveAgentConfigStub = sinon.stub(mcpUtils, 'saveAgentConfig').resolves() + saveServerSpecificAgentConfigStub = sinon.stub(mcpUtils, 'saveServerSpecificAgentConfig').resolves() }) afterEach(async () => { @@ -225,12 +262,13 @@ describe('addServer()', () => { args: ['a'], env: { X: '1' }, timeout: 0, + disabled: false, __configPath__: 'path.json', } await mgr.addServer('newS', newCfg, 'path.json') - expect(saveAgentConfigStub.calledOnce).to.be.true + expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true expect(initOneStub.calledOnceWith('newS', sinon.match(newCfg))).to.be.true }) @@ -257,19 +295,20 @@ describe('addServer()', () => { url: 'https://api.example.com/mcp', headers: { Authorization: 'Bearer 123' }, timeout: 0, + disabled: false, __configPath__: 'http.json', } await mgr.addServer('httpSrv', httpCfg, 'http.json') - expect(saveAgentConfigStub.calledOnce).to.be.true + expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true expect(initOneStub.calledOnceWith('httpSrv', sinon.match(httpCfg))).to.be.true }) }) describe('removeServer()', () => { let loadStub: sinon.SinonStub - let saveAgentConfigStub: sinon.SinonStub + let saveServerSpecificAgentConfigStub: sinon.SinonStub let existsStub: sinon.SinonStub let readFileStub: sinon.SinonStub let writeFileStub: sinon.SinonStub @@ -279,7 +318,7 @@ describe('removeServer()', () => { beforeEach(() => { loadStub = stubAgentConfig() - saveAgentConfigStub = sinon.stub(mcpUtils, 'saveAgentConfig').resolves() + saveServerSpecificAgentConfigStub = sinon.stub(mcpUtils, 'saveServerSpecificAgentConfig').resolves() existsStub = sinon.stub(fakeWorkspace.fs, 'exists').resolves(true) readFileStub = sinon .stub(fakeWorkspace.fs, 'readFile') @@ -308,6 +347,7 @@ describe('removeServer()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'c.json', } as MCPServerConfig) ;(mgr as any).serverNameMapping.set('x', 'x') @@ -324,11 +364,11 @@ describe('removeServer()', () => { } await mgr.removeServer('x') - expect(saveAgentConfigStub.calledOnce).to.be.true + expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true expect((mgr as any).clients.has('x')).to.be.false }) - it('removes server from all config files', async () => { + it('removes server from agent config', async () => { const mgr = await McpManager.init([], features) const dummy = new Client({ name: 'c', version: 'v' }) ;(mgr as any).clients.set('x', dummy) @@ -337,6 +377,7 @@ describe('removeServer()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'c.json', } as MCPServerConfig) ;(mgr as any).serverNameMapping.set('x', 'x') @@ -354,14 +395,13 @@ describe('removeServer()', () => { await mgr.removeServer('x') - // Verify that writeFile was called for each config path (2 workspace + 1 global) - expect(writeFileStub.callCount).to.equal(3) + // Verify that saveServerSpecificAgentConfig was called + expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true + expect((mgr as any).clients.has('x')).to.be.false - // Verify the content of the writes (should have removed the server) - writeFileStub.getCalls().forEach(call => { - const content = JSON.parse(call.args[1]) - expect(content.mcpServers).to.not.have.property('x') - }) + // Verify server was removed from agent config + expect((mgr as any).agentConfig.mcpServers).to.not.have.property('x') + expect((mgr as any).agentConfig.tools).to.not.include('@x') }) }) @@ -432,11 +472,11 @@ describe('mutateConfigFile()', () => { describe('updateServer()', () => { let loadStub: sinon.SinonStub let initOneStub: sinon.SinonStub - let saveAgentConfigStub: sinon.SinonStub + let saveServerSpecificAgentConfigStub: sinon.SinonStub beforeEach(() => { initOneStub = stubInitOneServer() - saveAgentConfigStub = sinon.stub(mcpUtils, 'saveAgentConfig').resolves() + saveServerSpecificAgentConfigStub = sinon.stub(mcpUtils, 'saveServerSpecificAgentConfig').resolves() }) afterEach(async () => { @@ -479,11 +519,11 @@ describe('updateServer()', () => { const closeStub = sinon.stub(fakeClient, 'close').resolves() initOneStub.resetHistory() - saveAgentConfigStub.resetHistory() + saveServerSpecificAgentConfigStub.resetHistory() await mgr.updateServer('u1', { timeout: 999 }, 'u.json') - expect(saveAgentConfigStub.calledOnce).to.be.true + expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true expect(closeStub.calledOnce).to.be.true expect(initOneStub.calledOnceWith('u1', sinon.match.has('timeout', 999))).to.be.true }) @@ -494,6 +534,7 @@ describe('updateServer()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'z.json', } @@ -518,11 +559,11 @@ describe('updateServer()', () => { const mgr = McpManager.instance initOneStub.resetHistory() - saveAgentConfigStub.resetHistory() + saveServerSpecificAgentConfigStub.resetHistory() await mgr.updateServer('srv', { command: undefined, url: 'https://new.host/mcp' }, 'z.json') - expect(saveAgentConfigStub.calledOnce).to.be.true + expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true expect(initOneStub.calledOnceWith('srv', sinon.match({ url: 'https://new.host/mcp' }))).to.be.true }) }) @@ -637,6 +678,7 @@ describe('getServerState()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'state.json', } loadStub.resolves({ @@ -678,6 +720,7 @@ describe('getAllServerStates()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'state.json', } loadStub.resolves({ @@ -726,6 +769,7 @@ describe('getEnabledTools()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 't.json', } @@ -767,6 +811,38 @@ describe('getEnabledTools()', () => { } expect(mgr.getEnabledTools()).to.be.empty }) + + it('filters out tools from disabled servers', async () => { + const disabledCfg: MCPServerConfig = { + command: 'c', + args: [], + env: {}, + timeout: 0, + disabled: true, + __configPath__: 't.json', + } + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map([['srv', disabledCfg]]), + serverNameMapping: new Map([['srv', 'srv']]), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: { srv: disabledCfg }, + tools: ['@srv'], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + + const mgr = await McpManager.init(['t.json'], features) + // Should be empty because server is disabled + expect(mgr.getEnabledTools()).to.be.empty + }) }) describe('getAllToolsWithPermissions()', () => { @@ -779,6 +855,7 @@ describe('getAllToolsWithPermissions()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'p.json', } @@ -828,6 +905,109 @@ describe('getAllToolsWithPermissions()', () => { }) }) +describe('isServerDisabled()', () => { + let loadStub: sinon.SinonStub + + afterEach(async () => { + sinon.restore() + try { + await McpManager.instance.close() + } catch {} + }) + + it('returns true when server is disabled', async () => { + const disabledCfg: MCPServerConfig = { + command: 'c', + args: [], + env: {}, + timeout: 0, + disabled: true, + __configPath__: 'p.json', + } + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map([['srv', disabledCfg]]), + serverNameMapping: new Map(), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: { srv: disabledCfg }, + tools: ['@srv'], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + + const mgr = await McpManager.init(['p.json'], features) + expect(mgr.isServerDisabled('srv')).to.be.true + }) + + it('returns false when server is enabled', async () => { + const enabledCfg: MCPServerConfig = { + command: 'c', + args: [], + env: {}, + timeout: 0, + disabled: false, + __configPath__: 'p.json', + } + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map([['srv', enabledCfg]]), + serverNameMapping: new Map(), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: { srv: enabledCfg }, + tools: ['@srv'], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + + const mgr = await McpManager.init(['p.json'], features) + expect(mgr.isServerDisabled('srv')).to.be.false + }) + + it('returns false when disabled property is undefined', async () => { + const undefinedCfg: MCPServerConfig = { + command: 'c', + args: [], + env: {}, + timeout: 0, + __configPath__: 'p.json', + } + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map([['srv', undefinedCfg]]), + serverNameMapping: new Map(), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: { srv: undefinedCfg }, + tools: ['@srv'], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + + const mgr = await McpManager.init(['p.json'], features) + expect(mgr.isServerDisabled('srv')).to.be.false + }) +}) + describe('close()', () => { let loadStub: sinon.SinonStub @@ -881,9 +1061,11 @@ describe('listServersAndTools()', () => { describe('updateServerPermission()', () => { let saveAgentConfigStub: sinon.SinonStub + let saveServerSpecificAgentConfigStub: sinon.SinonStub beforeEach(() => { saveAgentConfigStub = sinon.stub(mcpUtils, 'saveAgentConfig').resolves() + saveServerSpecificAgentConfigStub = sinon.stub(mcpUtils, 'saveServerSpecificAgentConfig').resolves() }) afterEach(async () => { @@ -899,6 +1081,7 @@ describe('updateServerPermission()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'x.json', } @@ -931,8 +1114,8 @@ describe('updateServerPermission()', () => { __configPath__: '/p', }) - // Verify saveAgentConfig was called - expect(saveAgentConfigStub.calledOnce).to.be.true + // Verify saveServerSpecificAgentConfig was called + expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true // Verify the tool permission was updated expect(mgr.requiresApproval('srv', 'tool1')).to.be.false @@ -953,6 +1136,7 @@ describe('reinitializeMcpServers()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'a.json', } const cfg2: MCPServerConfig = { @@ -960,6 +1144,7 @@ describe('reinitializeMcpServers()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'b.json', } const loadStub = sinon @@ -1085,6 +1270,7 @@ describe('concurrent server initialization', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: `config${i}.json`, } } 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 720c25f045..d0fe8ad9cc 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 @@ -26,6 +26,7 @@ import { isEmptyEnv, loadAgentConfig, saveAgentConfig, + saveServerSpecificAgentConfig, sanitizeName, getGlobalAgentConfigPath, getWorkspaceMcpConfigPaths, @@ -37,9 +38,15 @@ import { Mutex } from 'async-mutex' import path = require('path') import { URI } from 'vscode-uri' import { sanitizeInput } from '../../../../shared/utils' +import { ProfileStatusMonitor } from './profileStatusMonitor' +import { OAuthClient } from './mcpOauthClient' export const MCP_SERVER_STATUS_CHANGED = 'mcpServerStatusChanged' export const AGENT_TOOLS_CHANGED = 'agentToolsChanged' +export enum AuthIntent { + Interactive = 'interactive', + Silent = 'silent', +} /** * Manages MCP servers and their tools @@ -85,12 +92,19 @@ export class McpManager { if (!McpManager.#instance) { const mgr = new McpManager(agentPaths, features) McpManager.#instance = mgr - await mgr.discoverAllServers() - features.logging.info(`MCP: discovered ${mgr.mcpTools.length} tools across all servers`) + + const shouldDiscoverServers = ProfileStatusMonitor.getMcpState() + + if (shouldDiscoverServers) { + await mgr.discoverAllServers() + features.logging.info(`MCP: discovered ${mgr.mcpTools.length} tools across all servers`) + } else { + features.logging.info('MCP: initialized without server discovery') + } // Emit MCP configuration metrics const serverConfigs = mgr.getAllServerConfigs() - const activeServers = Array.from(serverConfigs.entries()) + const activeServers = Array.from(serverConfigs.entries()).filter(([name, _]) => !mgr.isServerDisabled(name)) // Count global vs project servers const globalServers = Array.from(serverConfigs.entries()).filter( @@ -178,14 +192,49 @@ export class McpManager { // Reset permissions map this.mcpServerPermissions.clear() - - // Initialize permissions for servers from agent config + // Create init state for (const [sanitizedName, _] of this.mcpServers.entries()) { - const name = this.serverNameMapping.get(sanitizedName) || sanitizedName - // Set server status to UNINITIALIZED initially this.setState(sanitizedName, McpServerStatus.UNINITIALIZED, 0) + } + // Get all servers that need to be initialized + const serversToInit: Array<[string, MCPServerConfig]> = [] + + for (const [name, cfg] of this.mcpServers.entries()) { + if (this.isServerDisabled(name)) { + this.features.logging.info(`MCP: server '${name}' is disabled by persona settings, skipping`) + this.setState(name, McpServerStatus.DISABLED, 0) + this.emitToolsChanged(name) + continue + } + serversToInit.push([name, cfg]) + } + + // Process servers in batches of 5 at a time + const MAX_CONCURRENT_SERVERS = 5 + const totalServers = serversToInit.length + + if (totalServers > 0) { + this.features.logging.info( + `MCP: initializing ${totalServers} servers with max concurrency of ${MAX_CONCURRENT_SERVERS}` + ) + + // Process servers in batches + for (let i = 0; i < totalServers; i += MAX_CONCURRENT_SERVERS) { + const batch = serversToInit.slice(i, i + MAX_CONCURRENT_SERVERS) + const batchPromises = batch.map(([name, cfg]) => this.initOneServer(name, cfg, AuthIntent.Silent)) + + this.features.logging.debug( + `MCP: initializing batch of ${batch.length} servers (${i + 1}-${Math.min(i + MAX_CONCURRENT_SERVERS, totalServers)} of ${totalServers})` + ) + await Promise.all(batchPromises) + } + + this.features.logging.info(`MCP: completed initialization of ${totalServers} servers`) + } + for (const [sanitizedName, _] of this.mcpServers.entries()) { + const name = this.serverNameMapping.get(sanitizedName) || sanitizedName // Initialize permissions for this server const serverPrefix = `@${name}` @@ -208,9 +257,18 @@ export class McpManager { }) } else { // Only specific tools are enabled + // get allTools of this server, if it's not in tools --> it's denied + // have to move the logic after all servers finish init, because that's when we have list of tools + const deniedTools = new Set( + this.getAllTools() + .filter(tool => tool.serverName === name) + .map(tool => tool.toolName) + ) this.agentConfig.tools.forEach(tool => { if (tool.startsWith(serverPrefix + '/')) { + // remove this from deniedTools const toolName = tool.substring(serverPrefix.length + 1) + deniedTools.delete(toolName) if (toolName) { // Check if tool is in allowedTools if (this.agentConfig.allowedTools.includes(tool)) { @@ -221,6 +279,11 @@ export class McpManager { } } }) + + // update permission to deny for rest of the tools + deniedTools.forEach(tool => { + toolPerms[tool] = McpPermissionType.deny + }) } this.mcpServerPermissions.set(sanitizedName, { @@ -228,43 +291,17 @@ export class McpManager { toolPerms, }) } - - // Get all servers that need to be initialized - const serversToInit: Array<[string, MCPServerConfig]> = [] - - for (const [name, cfg] of this.mcpServers.entries()) { - serversToInit.push([name, cfg]) - } - - // Process servers in batches of 5 at a time - const MAX_CONCURRENT_SERVERS = 5 - const totalServers = serversToInit.length - - if (totalServers > 0) { - this.features.logging.info( - `MCP: initializing ${totalServers} servers with max concurrency of ${MAX_CONCURRENT_SERVERS}` - ) - - // Process servers in batches - for (let i = 0; i < totalServers; i += MAX_CONCURRENT_SERVERS) { - const batch = serversToInit.slice(i, i + MAX_CONCURRENT_SERVERS) - const batchPromises = batch.map(([name, cfg]) => this.initOneServer(name, cfg)) - - this.features.logging.debug( - `MCP: initializing batch of ${batch.length} servers (${i + 1}-${Math.min(i + MAX_CONCURRENT_SERVERS, totalServers)} of ${totalServers})` - ) - await Promise.all(batchPromises) - } - - this.features.logging.info(`MCP: completed initialization of ${totalServers} servers`) - } } /** * Start a server process, connect client, and register its tools. * Errors are logged but do not stop discovery of other servers. */ - private async initOneServer(serverName: string, cfg: MCPServerConfig): Promise { + private async initOneServer( + serverName: string, + cfg: MCPServerConfig, + authIntent: AuthIntent = AuthIntent.Silent + ): Promise { const DEFAULT_SERVER_INIT_TIMEOUT_MS = 60_000 this.setState(serverName, McpServerStatus.INITIALIZING, 0) @@ -272,7 +309,7 @@ export class McpManager { this.features.logging.debug(`MCP: initializing server [${serverName}]`) const client = new Client({ - name: `mcp-client-${serverName}`, + name: `q-chat-plugin`, // Do not use server name in the client name to avoid polluting builder-mcp metrics version: '1.0.0', }) @@ -280,6 +317,7 @@ export class McpManager { const isStdio = !!cfg.command const doConnect = async () => { if (isStdio) { + // stdio transport const mergedEnv = { ...(process.env as Record), // Make sure we do not have empty key and value in mergedEnv, or adding server through UI will fail on Windows @@ -320,11 +358,52 @@ export class McpManager { ) } } else { + // streamable http/SSE transport const base = new URL(cfg.url!) try { + // Use HEAD to check if it needs OAuth + let headers: Record = { ...(cfg.headers ?? {}) } + let needsOAuth = false + try { + const headResp = await fetch(base, { method: 'HEAD', headers }) + const www = headResp.headers.get('www-authenticate') || '' + needsOAuth = headResp.status === 401 || headResp.status === 403 || /bearer/i.test(www) + } catch { + this.features.logging.info(`MCP: HEAD not available`) + } + + if (needsOAuth) { + OAuthClient.initialize(this.features.workspace, this.features.logging) + try { + const bearer = await OAuthClient.getValidAccessToken(base, { + interactive: authIntent === AuthIntent.Interactive, + }) + if (bearer) { + headers = { ...headers, Authorization: `Bearer ${bearer}` } + } else if (authIntent === AuthIntent.Silent) { + throw new AgenticChatError( + `MCP: server '${serverName}' requires OAuth. Open "Edit MCP Server" and save to sign in.`, + 'MCPServerAuthFailed' + ) + } + } catch (e: any) { + const msg = e?.message || '' + const short = /authorization_timed_out/i.test(msg) + ? 'Sign-in timed out. Please try again.' + : /Authorization error|PKCE|access_denied|login|consent|token exchange failed/i.test( + msg + ) + ? 'Sign-in was cancelled or failed. Please try again.' + : `OAuth failed: ${msg}` + + throw new AgenticChatError(`MCP: ${short}`, 'MCPServerAuthFailed') + } + } + try { // try streamable http first - transport = new StreamableHTTPClientTransport(base, this.buildHttpOpts(cfg.headers)) + transport = new StreamableHTTPClientTransport(base, this.buildHttpOpts(headers)) + this.features.logging.info(`MCP: Connecting MCP server using StreamableHTTPClientTransport`) await client.connect(transport) } catch (err) { @@ -332,13 +411,14 @@ export class McpManager { this.features.logging.info( `MCP: streamable http connect failed for [${serverName}], fallback to SSEClientTransport: ${String(err)}` ) - transport = new SSEClientTransport(new URL(cfg.url!), this.buildSseOpts(cfg.headers)) + transport = new SSEClientTransport(new URL(cfg.url!), this.buildSseOpts(headers)) await client.connect(transport) } } catch (err: any) { let errorMessage = err?.message ?? String(err) + const oauthHint = /oauth/i.test(errorMessage) ? ' (OAuth)' : '' throw new AgenticChatError( - `MCP: server '${serverName}' failed to connect: ${errorMessage}`, + `MCP: server '${serverName}' failed to connect${oauthHint}: ${errorMessage}`, 'MCPServerConnectionFailed' ) } @@ -440,7 +520,9 @@ export class McpManager { * Return a list of all enabled tools. */ public getEnabledTools(): McpToolDefinition[] { - return this.mcpTools.filter(t => !this.isToolDisabled(t.serverName, t.toolName)) + return this.mcpTools.filter( + t => !this.isServerDisabled(t.serverName) && !this.isToolDisabled(t.serverName, t.toolName) + ) } /** @@ -452,8 +534,11 @@ export class McpManager { return false } + // Get unsanitized server name for prefix + const unsanitizedServerName = this.serverNameMapping.get(server) || server + // Check if the server is enabled as a whole (@server) - const serverPrefix = `@${server}` + const serverPrefix = `@${unsanitizedServerName}` const isWholeServerEnabled = this.agentConfig.tools.includes(serverPrefix) // Check if the specific tool is enabled @@ -472,16 +557,10 @@ export class McpManager { /** * Returns true if the given server is currently disabled. */ - // public isServerDisabled(name: string): boolean { - // // Check if any tool from this server is enabled - // return !this.agentConfig.tools.some(tool => { - // if (tool.startsWith('@')) { - // // Check if it's the server itself or a tool from the server - // return tool === `@${name}` || tool.startsWith(`@${name}/`) - // } - // return false - // }) - // } + public isServerDisabled(name: string): boolean { + const cfg = this.mcpServers.get(name) + return cfg?.disabled ?? false + } /** * Returns tool permission type for a given tool. @@ -492,8 +571,11 @@ export class McpManager { return this.agentConfig.allowedTools.includes(tool) ? McpPermissionType.alwaysAllow : McpPermissionType.ask } + // Get unsanitized server name for prefix + const unsanitizedServerName = this.serverNameMapping.get(server) || server + // Check if the server is enabled as a whole (@server) - const serverPrefix = `@${server}` + const serverPrefix = `@${unsanitizedServerName}` const isWholeServerEnabled = this.agentConfig.tools.includes(serverPrefix) // Check if the specific tool is enabled @@ -550,7 +632,7 @@ export class McpManager { const cfg = this.mcpServers.get(server) if (!cfg) throw new Error(`MCP: server '${server}' is not configured`) - // if (this.isServerDisabled(server)) throw new Error(`MCP: server '${server}' is disabled`) + if (this.isServerDisabled(server)) throw new Error(`MCP: server '${server}' is disabled`) const available = this.getEnabledTools() .filter(t => t.serverName === server) @@ -613,9 +695,10 @@ export class McpManager { command: cfg.command, url: cfg.url, initializationTimeout: cfg.initializationTimeout, + disabled: cfg.disabled ?? false, } // Only add timeout to agent config if it's not 0 - if (cfg.timeout !== 0) { + if (cfg.timeout !== undefined) { serverConfig.timeout = cfg.timeout } if (cfg.args && cfg.args.length > 0) { @@ -648,11 +731,26 @@ export class McpManager { this.agentConfig.tools.push(serverPrefix) } - // Save agent config once with all changes - await saveAgentConfig(this.features.workspace, this.features.logging, this.agentConfig, agentPath) + // Save server-specific changes to agent config + const serverTools = this.agentConfig.tools.filter( + tool => tool === serverPrefix || tool.startsWith(`${serverPrefix}/`) + ) + const serverAllowedTools = this.agentConfig.allowedTools.filter( + tool => tool === serverPrefix || tool.startsWith(`${serverPrefix}/`) + ) + + await saveServerSpecificAgentConfig( + this.features.workspace, + this.features.logging, + serverName, + serverConfig, + serverTools, + serverAllowedTools, + agentPath + ) // Add server tools to tools list after initialization - await this.initOneServer(sanitizedName, newCfg) + await this.initOneServer(sanitizedName, newCfg, AuthIntent.Interactive) } catch (err) { this.features.logging.error( `Failed to add MCP server '${serverName}': ${err instanceof Error ? err.message : String(err)}` @@ -712,32 +810,16 @@ export class McpManager { return true }) - // Save agent config - await saveAgentConfig(this.features.workspace, this.features.logging, this.agentConfig, cfg.__configPath__) - - // Get all config paths and delete the server from each one - const wsUris = this.features.workspace.getAllWorkspaceFolders()?.map(f => f.uri) ?? [] - const wsConfigPaths = getWorkspaceMcpConfigPaths(wsUris) - const globalConfigPath = getGlobalMcpConfigPath(this.features.workspace.fs.getUserHomeDir()) - const allConfigPaths = [...wsConfigPaths, globalConfigPath] - - // Delete the server from all config files - for (const configPath of allConfigPaths) { - try { - await this.mutateConfigFile(configPath, json => { - if (json.mcpServers && json.mcpServers[unsanitizedName]) { - delete json.mcpServers[unsanitizedName] - this.features.logging.info( - `Deleted server '${unsanitizedName}' from config file: ${configPath}` - ) - } - }) - } catch (err) { - this.features.logging.warn( - `Failed to delete server '${unsanitizedName}' from config file ${configPath}: ${err}` - ) - } - } + // Save server removal to agent config + await saveServerSpecificAgentConfig( + this.features.workspace, + this.features.logging, + unsanitizedName, + null, // null indicates server should be removed + [], + [], + cfg.__configPath__ + ) } this.mcpServers.delete(serverName) @@ -790,10 +872,29 @@ export class McpManager { delete updatedConfig.env } } + if (configUpdates.disabled !== undefined) { + updatedConfig.disabled = configUpdates.disabled + } this.agentConfig.mcpServers[unsanitizedServerName] = updatedConfig - // Save agent config - await saveAgentConfig(this.features.workspace, this.features.logging, this.agentConfig, agentPath) + // Save server-specific changes to agent config + const serverPrefix = `@${unsanitizedServerName}` + const serverTools = this.agentConfig.tools.filter( + tool => tool === serverPrefix || tool.startsWith(`${serverPrefix}/`) + ) + const serverAllowedTools = this.agentConfig.allowedTools.filter( + tool => tool === serverPrefix || tool.startsWith(`${serverPrefix}/`) + ) + + await saveServerSpecificAgentConfig( + this.features.workspace, + this.features.logging, + unsanitizedServerName, + updatedConfig, + serverTools, + serverAllowedTools, + agentPath + ) } const newCfg: MCPServerConfig = { @@ -810,13 +911,12 @@ export class McpManager { this.mcpServers.set(serverName, newCfg) this.serverNameMapping.set(serverName, unsanitizedServerName) - // if (this.isServerDisabled(serverName)) { - // this.setState(serverName, McpServerStatus.DISABLED, 0) - // this.emitToolsChanged(serverName) - // } else { - // await this.initOneServer(serverName, newCfg) - // } - await this.initOneServer(serverName, newCfg) + if (this.isServerDisabled(serverName)) { + this.setState(serverName, McpServerStatus.DISABLED, 0) + this.emitToolsChanged(serverName) + } else { + await this.initOneServer(serverName, newCfg, AuthIntent.Interactive) + } } catch (err) { this.handleError(serverName, err) return @@ -872,7 +972,11 @@ export class McpManager { // Restore the saved tool name mapping this.setToolNameMapping(savedToolNameMapping) - await this.discoverAllServers() + const shouldDiscoverServers = ProfileStatusMonitor.getMcpState() + + if (shouldDiscoverServers) { + await this.discoverAllServers() + } const reinitializedServerCount = McpManager.#instance?.mcpServers.size this.features.logging.info( @@ -892,10 +996,10 @@ export class McpManager { const unsanitizedServerName = this.serverNameMapping.get(serverName) || serverName // Get server config - // const serverConfig = this.mcpServers.get(serverName) - // if (!serverConfig) { - // throw new Error(`Server '${serverName}' not found`) - // } + const serverConfig = this.mcpServers.get(serverName) + if (!serverConfig) { + throw new Error(`Server '${serverName}' not found`) + } const serverPrefix = `@${unsanitizedServerName}` @@ -993,18 +1097,59 @@ export class McpManager { } } - // Save agent config - const agentPath = perm.__configPath__ - if (agentPath) { - await saveAgentConfig(this.features.workspace, this.features.logging, this.agentConfig, agentPath) - } - - // Update mcpServerPermissions map + // Update mcpServerPermissions map immediately to reflect changes this.mcpServerPermissions.set(serverName, { enabled: perm.enabled, toolPerms: perm.toolPerms || {}, }) + // Update server enabled/disabled state in agent config + if (this.agentConfig.mcpServers[unsanitizedServerName]) { + this.agentConfig.mcpServers[unsanitizedServerName].disabled = !perm.enabled + } + + // Also update the mcpServers map + if (serverConfig) { + serverConfig.disabled = !perm.enabled + } + + // Save only server-specific changes to agent config + const agentPath = perm.__configPath__ + if (agentPath) { + // Collect server-specific tools and allowedTools + const serverPrefix = `@${unsanitizedServerName}` + const serverTools = this.agentConfig.tools.filter( + tool => tool === serverPrefix || tool.startsWith(`${serverPrefix}/`) + ) + const serverAllowedTools = this.agentConfig.allowedTools.filter( + tool => tool === serverPrefix || tool.startsWith(`${serverPrefix}/`) + ) + + await saveServerSpecificAgentConfig( + this.features.workspace, + this.features.logging, + unsanitizedServerName, + this.agentConfig.mcpServers[unsanitizedServerName], + serverTools, + serverAllowedTools, + agentPath + ) + } + + // enable/disable server + if (this.isServerDisabled(serverName)) { + const client = this.clients.get(serverName) + if (client) { + await client.close() + this.clients.delete(serverName) + } + this.setState(serverName, McpServerStatus.DISABLED, 0) + } else { + if (!this.clients.has(serverName) && serverName !== 'Built-in') { + await this.initOneServer(serverName, this.mcpServers.get(serverName)!, AuthIntent.Silent) + } + } + this.features.logging.info(`Permissions updated for '${serverName}' in agent config`) this.emitToolsChanged(serverName) } catch (err) { @@ -1018,10 +1163,23 @@ export class McpManager { */ public requiresApproval(server: string, tool: string): boolean { // For built-in tools, check directly without prefix - const toolId = server === 'builtIn' ? tool : `@${server}/${tool}` + if (server === 'builtIn') { + return !this.agentConfig.allowedTools.includes(tool) + } + + // Get unsanitized server name for prefix + const unsanitizedServerName = this.serverNameMapping.get(server) || server + const toolId = `@${unsanitizedServerName}/${tool}` return !this.agentConfig.allowedTools.includes(toolId) } + /** + * get server's tool permission + */ + public getMcpServerPermissions(serverName: string): MCPServerPermission | undefined { + return this.mcpServerPermissions.get(serverName) + } + /** * Returns any errors that occurred during loading of MCP configuration files */ @@ -1041,7 +1199,8 @@ export class McpManager { */ public async removeServerFromConfigFile(serverName: string): Promise { try { - const cfg = this.mcpServers.get(serverName) + const sanitized = sanitizeName(serverName) + const cfg = this.mcpServers.get(sanitized) if (!cfg || !cfg.__configPath__) { this.features.logging.warn( `Cannot remove config for server '${serverName}': Config not found or missing path` @@ -1049,7 +1208,7 @@ export class McpManager { return } - const unsanitizedName = this.serverNameMapping.get(serverName) || serverName + const unsanitizedName = this.serverNameMapping.get(sanitized) || serverName // Remove from agent config if (unsanitizedName && this.agentConfig) { @@ -1082,11 +1241,14 @@ export class McpManager { return true }) - // Save agent config - await saveAgentConfig( + // Save server removal to agent config + await saveServerSpecificAgentConfig( this.features.workspace, this.features.logging, - this.agentConfig, + unsanitizedName, + null, // null indicates server should be removed + [], + [], cfg.__configPath__ ) } @@ -1195,11 +1357,21 @@ export class McpManager { private handleError(server: string | undefined, err: unknown) { const msg = err instanceof Error ? err.message : String(err) - this.features.logging.error(`MCP ERROR${server ? ` [${server}]` : ''}: ${msg}`) + const isBenignSseDisconnect = + /SSE error:\s*TypeError:\s*terminated:\s*Body Timeout Error/i.test(msg) || + /TypeError:\s*terminated:\s*Body Timeout Error/i.test(msg) || + /TypeError:\s*terminated:\s*other side closed/i.test(msg) || + /ECONNRESET|ENETRESET|EPIPE/i.test(msg) - if (server) { - this.setState(server, McpServerStatus.FAILED, 0, msg) - this.emitToolsChanged(server) + if (isBenignSseDisconnect) { + this.features.logging.debug(`MCP SSE idle timeout${server ? ` [${server}]` : ''}: ${msg}`) + } else { + // default path for real errors + this.features.logging.error(`MCP ERROR${server ? ` [${server}]` : ''}: ${msg}`) + if (server) { + this.setState(server, McpServerStatus.FAILED, 0, msg) + this.emitToolsChanged(server) + } } } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.test.ts new file mode 100644 index 0000000000..08f8f1dd5c --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.test.ts @@ -0,0 +1,122 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. + * All Rights Reserved. SPDX-License-Identifier: Apache-2.0 + */ + +import { expect } from 'chai' +import * as sinon from 'sinon' +import * as crypto from 'crypto' +import * as http from 'http' +import { EventEmitter } from 'events' +import * as path from 'path' +import { OAuthClient } from './mcpOauthClient' + +const fakeLogger = { + log: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +} + +const fakeWorkspace = { + fs: { + exists: async (_path: string) => false, + readFile: async (_path: string) => Buffer.from('{}'), + writeFile: async (_path: string, _d: any) => {}, + mkdir: async (_dir: string, _opts: any) => {}, + }, +} as any + +function stubFileSystem(tokenObj?: any, regObj?: any): void { + const cacheDir = (OAuthClient as any).cacheDir as string + const tokPath = path.join(cacheDir, 'testkey.token.json') + const regPath = path.join(cacheDir, 'testkey.registration.json') + + const existsStub = sinon.stub(fakeWorkspace.fs, 'exists') + existsStub.callsFake(async (p: any) => { + if (p === tokPath && tokenObj) return true + if (p === regPath && regObj) return true + return false + }) + + const readStub = sinon.stub(fakeWorkspace.fs, 'readFile') + readStub.callsFake(async (p: any) => { + if (p === tokPath && tokenObj) return Buffer.from(JSON.stringify(tokenObj)) + if (p === regPath && regObj) return Buffer.from(JSON.stringify(regObj)) + return Buffer.from('{}') + }) + + sinon.stub(fakeWorkspace.fs, 'writeFile').resolves() + sinon.stub(fakeWorkspace.fs, 'mkdir').resolves() +} + +function stubHttpServer(): void { + sinon.stub(http, 'createServer').callsFake(() => { + const srv = new EventEmitter() as unknown as http.Server & EventEmitter + ;(srv as any).address = () => ({ address: '127.0.0.1', port: 12345, family: 'IPv4' }) + ;(srv as any).listen = (_port?: any, _host?: any, _backlog?: any, cb?: any) => { + if (typeof cb === 'function') cb() + // simulate async readiness like a real server + process.nextTick(() => srv.emit('listening')) + return srv + } + ;(srv as any).close = (cb?: any) => { + if (typeof cb === 'function') cb() + srv.removeAllListeners() + return srv + } + return srv + }) +} + +describe('OAuthClient helpers', () => { + it('computeKey() generates deterministic SHA-256 hex', () => { + const url = new URL('https://example.com/api') + const expected = crypto + .createHash('sha256') + .update(url.origin + url.pathname) + .digest('hex') + const actual = (OAuthClient as any).computeKey(url) + expect(actual).to.equal(expected) + }) + + it('b64url() strips padding and is URL-safe', () => { + const buf = Buffer.from('hello') + const actual = (OAuthClient as any).b64url(buf) + expect(actual).to.equal('aGVsbG8') + }) +}) + +describe('OAuthClient getValidAccessToken()', () => { + const now = Date.now() + + beforeEach(() => { + sinon.restore() + OAuthClient.initialize(fakeWorkspace, fakeLogger as any) + sinon.stub(OAuthClient as any, 'computeKey').returns('testkey') + stubHttpServer() + }) + + afterEach(() => sinon.restore()) + + it('returns cached token when still valid', async () => { + const cachedToken = { + access_token: 'cached_access', + expires_in: 3600, + obtained_at: now - 1_000, + } + const cachedReg = { + client_id: 'cid', + redirect_uri: 'http://localhost:12345', + } + + stubFileSystem(cachedToken, cachedReg) + + const token = await OAuthClient.getValidAccessToken(new URL('https://api.example.com/mcp'), { + interactive: true, + }) + expect(token).to.equal('cached_access') + expect((http.createServer as any).calledOnce).to.be.true + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.ts new file mode 100644 index 0000000000..1e9c8745d3 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.ts @@ -0,0 +1,491 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. + * All Rights Reserved. SPDX-License-Identifier: Apache-2.0 + */ + +import type { RequestInit } from 'node-fetch' +import * as crypto from 'crypto' +import * as path from 'path' +import { spawn } from 'child_process' +import { URL, URLSearchParams } from 'url' +import * as http from 'http' +import * as os from 'os' +import { Logger, Workspace } from '@aws/language-server-runtimes/server-interface' + +interface Token { + access_token: string + expires_in: number + refresh_token?: string + obtained_at: number +} + +interface Meta { + authorization_endpoint: string + token_endpoint: string + registration_endpoint?: string +} + +interface Registration { + client_id: string + client_secret?: string + expires_at?: number + redirect_uri: string +} + +export class OAuthClient { + private static logger: Logger + private static workspace: Workspace + + public static initialize(ws: Workspace, logger: Logger): void { + this.workspace = ws + this.logger = logger + } + + /** + * Return a valid Bearer token, reusing cache or refresh-token if possible, + * otherwise (when interactive) driving one PKCE flow that may launch a browser. + */ + public static async getValidAccessToken( + mcpBase: URL, + opts: { interactive?: boolean } = { interactive: false } + ): Promise { + const interactive = opts?.interactive === true + const key = this.computeKey(mcpBase) + const regPath = path.join(this.cacheDir, `${key}.registration.json`) + const tokPath = path.join(this.cacheDir, `${key}.token.json`) + + // ===== Silent branch: try cached token, then refresh, never opens a browser ===== + if (!interactive) { + // 1) cached access token + const cachedTok = await this.read(tokPath) + if (cachedTok) { + const expiry = cachedTok.obtained_at + cachedTok.expires_in * 1000 + if (Date.now() < expiry) { + this.logger.info(`OAuth: using still-valid cached token (silent)`) + return cachedTok.access_token + } + this.logger.info(`OAuth: cached token expired → try refresh (silent)`) + } + + // 2) refresh-token grant (if we have registration and refresh token) + const savedReg = await this.read(regPath) + if (cachedTok?.refresh_token && savedReg) { + try { + const meta = await this.discoverAS(mcpBase) + const refreshed = await this.refreshGrant(meta, savedReg, mcpBase, cachedTok.refresh_token) + if (refreshed) { + await this.write(tokPath, refreshed) + this.logger.info(`OAuth: refresh grant succeeded (silent)`) + return refreshed.access_token + } + this.logger.info(`OAuth: refresh grant did not succeed (silent)`) + } catch (e) { + this.logger.warn(`OAuth: silent refresh failed — ${e instanceof Error ? e.message : String(e)}`) + } + } + + // 3) no token in silent mode → caller should surface auth-required UI + return undefined + } + + // ===== Interactive branch: may open a browser (PKCE) ===== + // 1) Spin up (or reuse) loopback server + redirect URI + let server: http.Server | null = null + let redirectUri: string + const savedReg = await this.read(regPath) + if (savedReg) { + const port = Number(new URL(savedReg.redirect_uri).port) + server = http.createServer() + try { + await this.listen(server, port) + redirectUri = savedReg.redirect_uri + this.logger.info(`OAuth: reusing redirect URI ${redirectUri}`) + } catch (e: any) { + if (e.code === 'EADDRINUSE') { + try { + server.close() + } catch { + /* ignore */ + } + this.logger.warn(`Port ${port} in use; falling back to new random port`) + ;({ server, redirectUri } = await this.buildCallbackServer()) + this.logger.info(`OAuth: new redirect URI ${redirectUri}`) + await this.workspace.fs.rm(regPath) + } else { + throw e + } + } + } else { + const created = await this.buildCallbackServer() + server = created.server + redirectUri = created.redirectUri + this.logger.info(`OAuth: new redirect URI ${redirectUri}`) + } + + try { + // 2) Try still-valid cached access_token + const cached = await this.read(tokPath) + if (cached) { + const expiry = cached.obtained_at + cached.expires_in * 1000 + if (Date.now() < expiry) { + this.logger.info(`OAuth: using still-valid cached token`) + return cached.access_token + } + this.logger.info(`OAuth: cached token expired → try refresh`) + } + + // 3) Discover AS metadata + let meta: Meta + try { + meta = await this.discoverAS(mcpBase) + } catch (e: any) { + throw new Error(`OAuth discovery failed: ${e?.message ?? String(e)}`) + } + + // 4) Register (or reuse) a dynamic client + const scopes = ['openid', 'offline_access'] + let reg: Registration + try { + reg = await this.obtainClient(meta, regPath, scopes, redirectUri) + } catch (e: any) { + throw new Error(`OAuth client registration failed: ${e?.message ?? String(e)}`) + } + + // 5) Refresh-token grant (one shot) + const attemptedRefresh = !!cached?.refresh_token + if (cached?.refresh_token) { + const refreshed = await this.refreshGrant(meta, reg, mcpBase, cached.refresh_token) + if (refreshed) { + await this.write(tokPath, refreshed) + this.logger.info(`OAuth: refresh grant succeeded`) + return refreshed.access_token + } + this.logger.info(`OAuth: refresh grant failed`) + } + + // 6) PKCE interactive flow + try { + const fresh = await this.pkceGrant(meta, reg, mcpBase, scopes, redirectUri, server) + await this.write(tokPath, fresh) + return fresh.access_token + } catch (e: any) { + const suffix = attemptedRefresh ? ' after refresh attempt' : '' + throw new Error(`OAuth authorization (PKCE) failed${suffix}: ${e?.message ?? String(e)}`) + } + } finally { + if (server) { + await new Promise(res => server!.close(() => res())) + } + } + } + + /** Spin up a one‑time HTTP listener on localhost:randomPort */ + private static async buildCallbackServer(): Promise<{ server: http.Server; redirectUri: string }> { + const server = http.createServer() + await this.listen(server, 0) + const port = (server.address() as any).port as number + return { server, redirectUri: `http://localhost:${port}` } + } + + /** Discover OAuth endpoints by HEAD/WWW‑Authenticate, well‑known, or fallback */ + private static async discoverAS(rs: URL): Promise { + // a) HEAD → WWW‑Authenticate → resource_metadata + try { + this.logger.info('MCP OAuth: attempting discovery via WWW-Authenticate header') + const h = await this.fetchCompat(rs.toString(), { method: 'HEAD' }) + const header = h.headers.get('www-authenticate') || '' + const m = /resource_metadata=(?:"([^"]+)"|([^,\s]+))/i.exec(header) + if (m) { + const metaUrl = new URL(m[1] || m[2], rs).toString() + this.logger.info(`OAuth: resource_metadata → ${metaUrl}`) + const raw = await this.json(metaUrl) + return await this.fetchASFromResourceMeta(raw, metaUrl) + } + } catch { + this.logger.info('MCP OAuth: no resource_metadata found in WWW-Authenticate header') + } + + // b) well‑known on resource host + this.logger.info('MCP OAuth: attempting discovery via well-known endpoints') + const probes = [ + new URL('.well-known/oauth-authorization-server', rs).toString(), + new URL('.well-known/openid-configuration', rs).toString(), + `${rs.origin}/.well-known/oauth-authorization-server`, + `${rs.origin}/.well-known/openid-configuration`, + ] + for (const url of probes) { + try { + this.logger.info(`MCP OAuth: probing well-known endpoint → ${url}`) + return await this.json(url) + } catch (error) { + this.logger.info(`OAuth: well-known endpoint probe failed for ${url}`) + } + } + + // c) fallback to static OAuth2 endpoints + const base = (rs.origin + rs.pathname).replace(/\/+$/, '') + this.logger.warn(`OAuth: all discovery attempts failed, synthesizing endpoints from ${base}`) + return { + authorization_endpoint: `${base}/authorize`, + token_endpoint: `${base}/access_token`, + } + } + + /** Follow `authorization_server(s)` in resource_metadata JSON */ + private static async fetchASFromResourceMeta(raw: any, metaUrl: string): Promise { + let asBase = raw.authorization_server + if (!asBase && Array.isArray(raw.authorization_servers)) { + asBase = raw.authorization_servers[0] + } + if (!asBase) { + throw new Error(`resource_metadata at ${metaUrl} lacked authorization_server(s)`) + } + + // Attempt both OAuth‑AS and OIDC well‑known + for (const p of ['.well-known/oauth-authorization-server', '.well-known/openid-configuration']) { + try { + return await this.json(new URL(p, asBase).toString()) + } catch { + // next + } + } + // fallback to static OAuth2 endpoints + this.logger.warn(`OAuth: no well-known on ${asBase}, falling back to static endpoints`) + return { + authorization_endpoint: `${asBase}/authorize`, + token_endpoint: `${asBase}/access_token`, + } + } + + /** DCR: POST client metadata → client_id; cache to disk */ + private static async obtainClient( + meta: Meta, + file: string, + scopes: string[], + redirectUri: string + ): Promise { + const existing = await this.read(file) + if (existing && (!existing.expires_at || existing.expires_at * 1000 > Date.now())) { + this.logger.info(`OAuth: reusing client_id ${existing.client_id}`) + return existing + } + + if (!meta.registration_endpoint) { + throw new Error('OAuth: AS does not support dynamic registration') + } + + const body = { + client_name: 'AWS MCP LSP', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + scope: scopes.join(' '), + redirect_uris: [redirectUri], + } + const resp: any = await this.json(meta.registration_endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) + + const reg: Registration = { + client_id: resp.client_id, + client_secret: resp.client_secret, + expires_at: resp.client_secret_expires_at, + redirect_uri: redirectUri, + } + await this.write(file, reg) + return reg + } + + /** Try one refresh_token grant; returns new Token or `undefined` */ + private static async refreshGrant( + meta: Meta, + reg: Registration, + rs: URL, + refresh: string + ): Promise { + const form = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refresh, + client_id: reg.client_id, + resource: rs.toString(), + }) + const res = await this.fetchCompat(meta.token_endpoint, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: form, + }) + if (!res.ok) { + const msg = await res.text().catch(() => '') + this.logger.warn(`OAuth: refresh grant HTTP ${res.status} — ${msg?.slice(0, 300)}`) + return undefined + } + const tokenResponse = (await res.json()) as Record + return { ...(tokenResponse as object), obtained_at: Date.now() } as Token + } + + /** One PKCE flow: browser + loopback → code → token */ + private static async pkceGrant( + meta: Meta, + reg: Registration, + rs: URL, + scopes: string[], + redirectUri: string, + server: http.Server + ): Promise { + const DEFAULT_PKCE_TIMEOUT_MS = 20_000 + // a) generate PKCE params + const verifier = this.b64url(crypto.randomBytes(32)) + const challenge = this.b64url(crypto.createHash('sha256').update(verifier).digest()) + const state = this.b64url(crypto.randomBytes(16)) + + // b) build authorize URL + launch browser + const authz = new URL(meta.authorization_endpoint) + authz.search = new URLSearchParams({ + client_id: reg.client_id, + response_type: 'code', + code_challenge: challenge, + code_challenge_method: 'S256', + resource: rs.toString(), + scope: scopes.join(' '), + redirect_uri: redirectUri, + state: state, + }).toString() + + const opener = + process.platform === 'win32' + ? { + cmd: 'cmd', + args: ['/c', 'start', '', `"${authz.toString().replace(/"/g, '""')}"`], + } + : process.platform === 'darwin' + ? { cmd: 'open', args: [authz.toString()] } + : { cmd: 'xdg-open', args: [authz.toString()] } + + void spawn(opener.cmd, opener.args, { detached: true, stdio: 'ignore' }).unref() + + // c) wait for code on our loopback + const waitForFlow = new Promise<{ code: string; rxState: string; err?: string; errDesc?: string }>(resolve => { + server.on('request', (req, res) => { + const u = new URL(req.url || '/', redirectUri) + const c = u.searchParams.get('code') || '' + const s = u.searchParams.get('state') || '' + const e = u.searchParams.get('error') || undefined + const ed = u.searchParams.get('error_description') || undefined + res.writeHead(200, { 'content-type': 'text/html' }).end('

You may close this tab.

') + resolve({ code: c, rxState: s, err: e, errDesc: ed }) + }) + }) + const { code, rxState, err, errDesc } = await Promise.race([ + waitForFlow, + new Promise((_, reject) => + setTimeout(() => reject(new Error('authorization_timed_out')), DEFAULT_PKCE_TIMEOUT_MS) + ), + ]) + if (err) { + throw new Error(`Authorization error: ${err}${errDesc ? ` - ${errDesc}` : ''}`) + } + if (!code || rxState !== state) throw new Error('Invalid authorization response (state mismatch)') + + // d) exchange code for token + const form2 = new URLSearchParams({ + grant_type: 'authorization_code', + code, + code_verifier: verifier, + client_id: reg.client_id, + redirect_uri: redirectUri, + resource: rs.toString(), + }) + const res2 = await this.fetchCompat(meta.token_endpoint, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: form2, + }) + if (!res2.ok) { + const txt = await res2.text().catch(() => '') + throw new Error(`Token exchange failed (HTTP ${res2.status}): ${txt?.slice(0, 300)}`) + } + const tk = (await res2.json()) as Record + return { ...(tk as object), obtained_at: Date.now() } as Token + } + + /** Fetch + error‑check + parse JSON */ + private static async json(url: string, init?: RequestInit): Promise { + const r = await this.fetchCompat(url, init) + if (!r.ok) { + const txt = await r.text().catch(() => '') + throw new Error(`HTTP ${r.status}@${url} — ${txt}`) + } + return (await r.json()) as T + } + + /** Read & parse JSON file via workspace.fs */ + private static async read(file: string): Promise { + try { + if (!(await this.workspace.fs.exists(file))) return undefined + const buf = await this.workspace.fs.readFile(file) + return JSON.parse(buf.toString()) as T + } catch { + return undefined + } + } + + /** Write JSON, then clamp file perms to 0600 (owner read/write) */ + private static async write(file: string, obj: unknown): Promise { + const dir = path.dirname(file) + await this.workspace.fs.mkdir(dir, { recursive: true }) + await this.workspace.fs.writeFile(file, JSON.stringify(obj, null, 2), { mode: 0o600 }) + } + + /** SHA‑256 of resourceServer URL → hex key */ + private static computeKey(rs: URL): string { + return crypto + .createHash('sha256') + .update(rs.origin + rs.pathname) + .digest('hex') + } + + /** RFC‑7636 base64url without padding */ + private static b64url(buf: Buffer): string { + return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') + } + + /** Directory for caching registration + tokens */ + private static readonly cacheDir = path.join(os.homedir(), '.aws', 'sso', 'cache') + + /** + * Await server.listen() but reject if it emits 'error' (eg EADDRINUSE), + * so callers can handle it immediately instead of hanging. + */ + private static listen(server: http.Server, port: number, host: string = '127.0.0.1'): Promise { + return new Promise((resolve, reject) => { + const onListening = () => { + server.off('error', onError) + resolve() + } + const onError = (err: NodeJS.ErrnoException) => { + server.off('listening', onListening) + reject(err) + } + server.once('listening', onListening) + server.once('error', onError) + server.listen(port, host) + }) + } + + /** + * Fetch compatibility: use global fetch on Node >= 18, otherwise dynamically import('node-fetch'). + * Using Function('return import(...)') avoids downleveling to require() in CJS builds. + */ + private static async fetchCompat(url: string, init?: RequestInit): Promise { + const globalObj = globalThis as any + if (typeof globalObj.fetch === 'function') { + return globalObj.fetch(url as any, init as any) + } + // Dynamic import of ESM node-fetch (only when global fetch is unavailable) + const mod = await (Function('return import("node-fetch")')() as Promise) + const f = mod.default ?? mod + return f(url as any, init as any) + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts index df6a39cc86..6aec98cb24 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts @@ -35,6 +35,7 @@ export interface MCPServerConfig { timeout?: number url?: string headers?: Record + disabled?: boolean __configPath__?: string } export interface MCPServerPermission { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.test.ts index 0e7cce1286..43843bd110 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.test.ts @@ -15,13 +15,21 @@ import { getGlobalPersonaConfigPath, getWorkspaceAgentConfigPaths, getGlobalAgentConfigPath, + getWorkspaceMcpConfigPaths, + getGlobalMcpConfigPath, createNamespacedToolName, MAX_TOOL_NAME_LENGTH, enabledMCP, normalizePathFromUri, saveAgentConfig, + saveServerSpecificAgentConfig, + isEmptyEnv, + sanitizeName, + convertPersonaToAgent, + migrateToAgentConfig, } from './mcpUtils' import type { MCPServerConfig } from './mcpTypes' +import { McpPermissionType } from './mcpTypes' import { pathToFileURL } from 'url' import * as sinon from 'sinon' import { URI } from 'vscode-uri' @@ -614,3 +622,316 @@ describe('sanitizeContent', () => { expect(sanitizeInput(input)).to.equal(expected) }) }) + +describe('getWorkspaceMcpConfigPaths', () => { + it('returns correct paths for workspace MCP configs', () => { + const uris = ['uri1', 'uri2'] + const expected = [path.join('uri1', '.amazonq', 'mcp.json'), path.join('uri2', '.amazonq', 'mcp.json')] + expect(getWorkspaceMcpConfigPaths(uris)).to.deep.equal(expected) + }) +}) + +describe('getGlobalMcpConfigPath', () => { + it('returns correct global MCP config path', () => { + const homePath = path.resolve('home_dir') + const expected = path.join(homePath, '.aws', 'amazonq', 'mcp.json') + expect(getGlobalMcpConfigPath(homePath)).to.equal(expected) + }) +}) + +describe('isEmptyEnv', () => { + it('returns true for undefined env', () => { + expect(isEmptyEnv(undefined as any)).to.be.true + }) + + it('returns true for null env', () => { + expect(isEmptyEnv(null as any)).to.be.true + }) + + it('returns true for empty object', () => { + expect(isEmptyEnv({})).to.be.true + }) + + it('returns true for object with empty keys/values', () => { + expect(isEmptyEnv({ '': 'value', key: '' })).to.be.true + expect(isEmptyEnv({ ' ': ' ' })).to.be.true + }) + + it('returns false for object with valid key-value pairs', () => { + expect(isEmptyEnv({ KEY: 'value' })).to.be.false + expect(isEmptyEnv({ KEY1: 'value1', KEY2: 'value2' })).to.be.false + }) +}) + +describe('sanitizeName', () => { + it('returns original name if valid', () => { + expect(sanitizeName('valid_name-123')).to.equal('valid_name-123') + }) + + it('filters invalid characters', () => { + expect(sanitizeName('name@#$%')).to.equal('name') + expect(sanitizeName('name with spaces')).to.equal('namewithspaces') + }) + + it('removes namespace delimiter', () => { + expect(sanitizeName('server___tool')).to.equal('servertool') + }) + + it('returns hash for empty sanitized string', () => { + const result = sanitizeName('@#$%') + expect(result).to.have.length(3) + expect(/^[a-f0-9]+$/.test(result)).to.be.true + }) +}) + +describe('convertPersonaToAgent', () => { + let mockAgent: any + + beforeEach(() => { + mockAgent = { + getBuiltInToolNames: () => ['fs_read', 'execute_bash'], + getBuiltInWriteToolNames: () => ['fs_write'], + } + }) + + it('converts basic persona to agent config', () => { + const persona = { mcpServers: ['*'], toolPerms: {} } + const mcpServers = { testServer: { command: 'test', args: [], env: {} } } + + const result = convertPersonaToAgent(persona, mcpServers, mockAgent) + + expect(result.name).to.equal('default-agent') + expect(result.mcpServers).to.have.property('testServer') + expect(result.tools).to.include('@testServer') + expect(result.tools).to.include('fs_read') + expect(result.allowedTools).to.include('fs_read') + }) + + it('handles alwaysAllow permissions', () => { + const persona = { + mcpServers: ['testServer'], + toolPerms: { + testServer: { + tool1: McpPermissionType.alwaysAllow, + }, + }, + } + const mcpServers = { testServer: { command: 'test', args: [], env: {} } } + + const result = convertPersonaToAgent(persona, mcpServers, mockAgent) + + expect(result.allowedTools).to.include('@testServer/tool1') + }) +}) + +describe('migrateToAgentConfig', () => { + let tmpDir: string + let workspace: any + let logger: any + let mockAgent: any + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'migrateTest-')) + 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: (d: string, opts: any) => Promise.resolve(fs.mkdirSync(d, { recursive: opts.recursive })), + getUserHomeDir: () => tmpDir, + }, + getAllWorkspaceFolders: () => [], + } + logger = { warn: () => {}, info: () => {}, error: () => {} } + mockAgent = { + getBuiltInToolNames: () => ['fs_read'], + getBuiltInWriteToolNames: () => ['fs_write'], + } + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('migrates when no existing configs exist', async () => { + // Create empty MCP config to trigger migration + const mcpDir = path.join(tmpDir, '.aws', 'amazonq') + fs.mkdirSync(mcpDir, { recursive: true }) + const mcpPath = path.join(mcpDir, 'mcp.json') + fs.writeFileSync(mcpPath, JSON.stringify({ mcpServers: {} })) + + await migrateToAgentConfig(workspace, logger, mockAgent) + + // Should create default agent config + const agentPath = path.join(tmpDir, '.aws', 'amazonq', 'agents', 'default.json') + expect(fs.existsSync(agentPath)).to.be.true + }) + + it('migrates existing MCP config to agent config', async () => { + // Create MCP config + const mcpDir = path.join(tmpDir, '.aws', 'amazonq') + fs.mkdirSync(mcpDir, { recursive: true }) + const mcpPath = path.join(mcpDir, 'mcp.json') + fs.writeFileSync( + mcpPath, + JSON.stringify({ + mcpServers: { + testServer: { command: 'test-cmd', args: ['arg1'] }, + }, + }) + ) + + await migrateToAgentConfig(workspace, logger, mockAgent) + + const agentPath = path.join(tmpDir, '.aws', 'amazonq', 'agents', 'default.json') + expect(fs.existsSync(agentPath)).to.be.true + const agentConfig = JSON.parse(fs.readFileSync(agentPath, 'utf-8')) + expect(agentConfig.mcpServers).to.have.property('testServer') + }) +}) +describe('saveServerSpecificAgentConfig', () => { + let tmpDir: string + let workspace: any + let logger: any + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'saveServerSpecificTest-')) + 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: (d: string, opts: any) => Promise.resolve(fs.mkdirSync(d, { recursive: opts.recursive })), + }, + } + logger = { warn: () => {}, info: () => {}, error: () => {} } + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('creates new config file when it does not exist', async () => { + const configPath = path.join(tmpDir, 'agent-config.json') + const serverConfig = { command: 'test-cmd', args: ['arg1'] } + const serverTools = ['@testServer'] + const serverAllowedTools = ['@testServer/tool1'] + + await saveServerSpecificAgentConfig( + workspace, + logger, + 'testServer', + serverConfig, + serverTools, + serverAllowedTools, + configPath + ) + + expect(fs.existsSync(configPath)).to.be.true + const content = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + expect(content.mcpServers.testServer).to.deep.equal(serverConfig) + expect(content.tools).to.include('@testServer') + expect(content.allowedTools).to.include('@testServer/tool1') + }) + + it('updates existing config file', async () => { + const configPath = path.join(tmpDir, 'agent-config.json') + + // Create existing config + const existingConfig = { + name: 'existing-agent', + version: '1.0.0', + description: 'Existing agent', + mcpServers: { + existingServer: { command: 'existing-cmd' }, + }, + tools: ['fs_read', '@existingServer'], + allowedTools: ['fs_read'], + toolsSettings: {}, + includedFiles: [], + resources: [], + } + fs.writeFileSync(configPath, JSON.stringify(existingConfig)) + + const serverConfig = { command: 'new-cmd', args: ['arg1'] } + const serverTools = ['@newServer'] + const serverAllowedTools = ['@newServer/tool1'] + + await saveServerSpecificAgentConfig( + workspace, + logger, + 'newServer', + serverConfig, + serverTools, + serverAllowedTools, + configPath + ) + + const content = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + expect(content.name).to.equal('existing-agent') + expect(content.mcpServers.existingServer).to.deep.equal({ command: 'existing-cmd' }) + expect(content.mcpServers.newServer).to.deep.equal(serverConfig) + expect(content.tools).to.include('@newServer') + expect(content.allowedTools).to.include('@newServer/tool1') + }) + + it('removes existing server tools before adding new ones', async () => { + const configPath = path.join(tmpDir, 'agent-config.json') + + // Create existing config with server tools + const existingConfig = { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: { + testServer: { command: 'old-cmd' }, + }, + tools: ['fs_read', '@testServer', '@testServer/oldTool'], + allowedTools: ['fs_read', '@testServer/oldAllowedTool'], + toolsSettings: {}, + includedFiles: [], + resources: [], + } + fs.writeFileSync(configPath, JSON.stringify(existingConfig)) + + const serverConfig = { command: 'new-cmd' } + const serverTools = ['@testServer'] + const serverAllowedTools = ['@testServer/newTool'] + + await saveServerSpecificAgentConfig( + workspace, + logger, + 'testServer', + serverConfig, + serverTools, + serverAllowedTools, + configPath + ) + + const content = JSON.parse(fs.readFileSync(configPath, 'utf-8')) + expect(content.tools).to.not.include('@testServer/oldTool') + expect(content.allowedTools).to.not.include('@testServer/oldAllowedTool') + expect(content.tools).to.include('@testServer') + expect(content.allowedTools).to.include('@testServer/newTool') + expect(content.tools).to.include('fs_read') + }) + + it('creates parent directories if they do not exist', async () => { + const configPath = path.join(tmpDir, 'nested', 'dir', 'agent-config.json') + const serverConfig = { command: 'test-cmd' } + const serverTools = ['@testServer'] + const serverAllowedTools: string[] = [] + + await saveServerSpecificAgentConfig( + workspace, + logger, + 'testServer', + serverConfig, + serverTools, + serverAllowedTools, + configPath + ) + + expect(fs.existsSync(configPath)).to.be.true + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts index e30d76534f..ef56a54d22 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts @@ -163,7 +163,8 @@ const DEFAULT_AGENT_RAW = `{ "fs_read", "report_issue", "use_aws", - "execute_bash" + "execute_bash", + "fs_write" ], "toolsSettings": { "use_aws": { "preset": "readOnly" }, @@ -174,7 +175,9 @@ const DEFAULT_AGENT_RAW = `{ "README.md", ".amazonq/rules/**/*.md" ], - "resources": [] + "resources": [], + "createHooks": [], + "promptHooks": [] }` const DEFAULT_PERSONA_RAW = `{ @@ -262,8 +265,15 @@ export async function loadAgentConfig( const globalConfigPath = getGlobalAgentConfigPath(workspace.fs.getUserHomeDir()) + // Sort paths to process global config last + const sortedPaths = uniquePaths.sort((a, b) => { + if (a === globalConfigPath) return 1 + if (b === globalConfigPath) return -1 + return 0 + }) + // Process each path like loadMcpServerConfigs - for (const fsPath of uniquePaths) { + for (const fsPath of sortedPaths) { // 1) Skip missing files or create default global let exists: boolean try { @@ -327,7 +337,7 @@ export async function loadAgentConfig( // 4) Process permissions (tools and allowedTools) if (Array.isArray(json.tools)) { for (const tool of json.tools) { - if (!agentConfig.tools.includes(tool)) { + if (!tool.startsWith('@') && !agentConfig.tools.includes(tool)) { agentConfig.tools.push(tool) } } @@ -335,7 +345,7 @@ export async function loadAgentConfig( if (Array.isArray(json.allowedTools)) { for (const tool of json.allowedTools) { - if (!agentConfig.allowedTools.includes(tool)) { + if (!tool.startsWith('@') && !agentConfig.allowedTools.includes(tool)) { agentConfig.allowedTools.push(tool) } } @@ -379,6 +389,7 @@ export async function loadAgentConfig( ? (entry as any).initializationTimeout : undefined, timeout: typeof (entry as any).timeout === 'number' ? (entry as any).timeout : undefined, + disabled: typeof (entry as any).disabled === 'boolean' ? (entry as any).disabled : false, __configPath__: fsPath, // Store config path for determining global vs workspace } @@ -417,8 +428,32 @@ export async function loadAgentConfig( agentEntry.initializationTimeout = cfg.initializationTimeout } if (typeof cfg.timeout === 'number') agentEntry.timeout = cfg.timeout + agentEntry.disabled = cfg.disabled agentConfig.mcpServers[name] = agentEntry + // Add MCP server-specific tools and allowedTools after server is successfully added + if (Array.isArray(json.tools)) { + for (const tool of json.tools) { + if ( + (tool === `@${name}` || tool.startsWith(`@${name}/`)) && + !agentConfig.tools.includes(tool) + ) { + agentConfig.tools.push(tool) + } + } + } + + if (Array.isArray(json.allowedTools)) { + for (const tool of json.allowedTools) { + if ( + (tool === `@${name}` || tool.startsWith(`@${name}/`)) && + !agentConfig.allowedTools.includes(tool) + ) { + agentConfig.allowedTools.push(tool) + } + } + } + logging.info( `Loaded MCP server with sanitizedName: '${sanitizedName}' and originalName: '${name}' from ${fsPath}` ) @@ -807,48 +842,40 @@ async function migrateConfigToAgent( const normalizedPersonaPath = normalizePathFromUri(personaPath, logging) agentPath = normalizePathFromUri(agentPath) - // Check if agent config exists + // Check if config and agent files exist + const configExists = await workspace.fs.exists(normalizedConfigPath).catch(() => false) const agentExists = await workspace.fs.exists(agentPath).catch(() => false) - // Load existing agent config if it exists - let existingAgentConfig: AgentConfig | undefined + // Only migrate if agent file does not exist + // If config exists, migrate from it; if not, create default agent config if (agentExists) { - try { - const raw = (await workspace.fs.readFile(agentPath)).toString().trim() - existingAgentConfig = raw ? JSON.parse(raw) : undefined - } catch (err) { - logging.warn(`Failed to read existing agent config at ${agentPath}: ${err}`) - } + return } // Read MCP server configs directly from file const serverConfigs: Record = {} try { - const configExists = await workspace.fs.exists(normalizedConfigPath) - - if (configExists) { - const raw = (await workspace.fs.readFile(normalizedConfigPath)).toString().trim() - if (raw) { - const config = JSON.parse(raw) - - if (config.mcpServers && typeof config.mcpServers === 'object') { - // Add each server to the serverConfigs - for (const [name, serverConfig] of Object.entries(config.mcpServers)) { - serverConfigs[name] = { - command: (serverConfig as any).command, - args: Array.isArray((serverConfig as any).args) ? (serverConfig as any).args : undefined, - env: typeof (serverConfig as any).env === 'object' ? (serverConfig as any).env : undefined, - initializationTimeout: - typeof (serverConfig as any).initializationTimeout === 'number' - ? (serverConfig as any).initializationTimeout - : undefined, - timeout: - typeof (serverConfig as any).timeout === 'number' - ? (serverConfig as any).timeout - : undefined, - } - logging.info(`Added server ${name} to serverConfigs`) + const raw = (await workspace.fs.readFile(normalizedConfigPath)).toString().trim() + if (raw) { + const config = JSON.parse(raw) + + if (config.mcpServers && typeof config.mcpServers === 'object') { + // Add each server to the serverConfigs + for (const [name, serverConfig] of Object.entries(config.mcpServers)) { + serverConfigs[name] = { + command: (serverConfig as any).command, + args: Array.isArray((serverConfig as any).args) ? (serverConfig as any).args : undefined, + env: typeof (serverConfig as any).env === 'object' ? (serverConfig as any).env : undefined, + initializationTimeout: + typeof (serverConfig as any).initializationTimeout === 'number' + ? (serverConfig as any).initializationTimeout + : undefined, + timeout: + typeof (serverConfig as any).timeout === 'number' + ? (serverConfig as any).timeout + : undefined, } + logging.info(`Added server ${name} to serverConfigs`) } } } @@ -875,46 +902,24 @@ async function migrateConfigToAgent( } // Convert to agent config - const newAgentConfig = convertPersonaToAgent(personaConfig, serverConfigs, agent) - newAgentConfig.includedFiles = ['AmazonQ.md', 'README.md', '.amazonq/rules/**/*.md'] - newAgentConfig.resources = [] // Initialize with empty array - - // Merge with existing config if available - let finalAgentConfig: AgentConfig - if (existingAgentConfig) { - // Keep existing metadata - finalAgentConfig = { - ...existingAgentConfig, - // Merge MCP servers, keeping existing ones if they exist - mcpServers: { - ...existingAgentConfig.mcpServers, - ...newAgentConfig.mcpServers, - }, - // Merge tools lists without duplicates - tools: [...new Set([...existingAgentConfig.tools, ...newAgentConfig.tools])], - allowedTools: [...new Set([...existingAgentConfig.allowedTools, ...newAgentConfig.allowedTools])], - // Merge tool settings, preferring existing ones - toolsSettings: { - ...newAgentConfig.toolsSettings, - ...existingAgentConfig.toolsSettings, - }, - // Keep other properties from existing config - includedFiles: existingAgentConfig.includedFiles || newAgentConfig.includedFiles, - createHooks: existingAgentConfig.createHooks || newAgentConfig.createHooks, - promptHooks: [ - ...new Set([...(existingAgentConfig.promptHooks || []), ...(newAgentConfig.promptHooks || [])]), - ], - resources: [...new Set([...(existingAgentConfig.resources || []), ...(newAgentConfig.resources || [])])], - } - } else { - finalAgentConfig = newAgentConfig - logging.info(`Using new config (no existing config to merge)`) - } + const agentConfig = convertPersonaToAgent(personaConfig, serverConfigs, agent) + + // Parse default values from DEFAULT_AGENT_RAW + const defaultAgent = JSON.parse(DEFAULT_AGENT_RAW) + + // Add complete agent format sections using default values + agentConfig.name = defaultAgent.name + agentConfig.description = defaultAgent.description + agentConfig.version = defaultAgent.version + agentConfig.includedFiles = defaultAgent.includedFiles + agentConfig.resources = defaultAgent.resources + agentConfig.createHooks = defaultAgent.createHooks + agentConfig.promptHooks = defaultAgent.promptHooks // Save agent config try { - await saveAgentConfig(workspace, logging, finalAgentConfig, agentPath) - logging.info(`Successfully ${existingAgentConfig ? 'updated' : 'created'} agent config at ${agentPath}`) + await saveAgentConfig(workspace, logging, agentConfig, agentPath) + logging.info(`Successfully created agent config at ${agentPath}`) } catch (err) { logging.error(`Failed to save agent config to ${agentPath}: ${err}`) throw err @@ -929,6 +934,7 @@ export async function saveAgentConfig( ): Promise { try { await workspace.fs.mkdir(path.dirname(configPath), { recursive: true }) + // Save the whole config await workspace.fs.writeFile(configPath, JSON.stringify(config, null, 2)) logging.info(`Saved agent config to ${configPath}`) } catch (err: any) { @@ -937,6 +943,69 @@ export async function saveAgentConfig( } } +/** + * Save only server-specific changes to agent config file + */ +export async function saveServerSpecificAgentConfig( + workspace: Workspace, + logging: Logger, + serverName: string, + serverConfig: any, + serverTools: string[], + serverAllowedTools: string[], + configPath: string +): Promise { + try { + await workspace.fs.mkdir(path.dirname(configPath), { recursive: true }) + + // Read existing config + let existingConfig: AgentConfig + try { + const raw = await workspace.fs.readFile(configPath) + existingConfig = JSON.parse(raw.toString()) + } catch { + // If file doesn't exist, create minimal config + existingConfig = { + name: 'default-agent', + version: '1.0.0', + description: 'Agent configuration', + mcpServers: {}, + tools: [], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + } + } + + // Remove existing server tools from arrays + const serverPrefix = `@${serverName}` + existingConfig.tools = existingConfig.tools.filter( + tool => tool !== serverPrefix && !tool.startsWith(`${serverPrefix}/`) + ) + existingConfig.allowedTools = existingConfig.allowedTools.filter( + tool => tool !== serverPrefix && !tool.startsWith(`${serverPrefix}/`) + ) + + if (serverConfig === null) { + // Remove server entirely + delete existingConfig.mcpServers[serverName] + } else { + // Update or add server + existingConfig.mcpServers[serverName] = serverConfig + // Add new server tools + existingConfig.tools.push(...serverTools) + existingConfig.allowedTools.push(...serverAllowedTools) + } + + await workspace.fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2)) + logging.info(`Saved server-specific agent config for ${serverName} to ${configPath}`) + } catch (err: any) { + logging.error(`Failed to save server-specific agent config to ${configPath}: ${err.message}`) + throw err + } +} + export const MAX_TOOL_NAME_LENGTH = 64 /** diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.test.ts new file mode 100644 index 0000000000..77080bf08a --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.test.ts @@ -0,0 +1,182 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. + * All Rights Reserved. SPDX-License-Identifier: Apache-2.0 + */ + +import { expect } from 'chai' +import * as sinon from 'sinon' +import { ProfileStatusMonitor } from './profileStatusMonitor' +import * as AmazonQTokenServiceManagerModule from '../../../../shared/amazonQServiceManager/AmazonQTokenServiceManager' + +describe('ProfileStatusMonitor', () => { + let profileStatusMonitor: ProfileStatusMonitor + let mockCredentialsProvider: any + let mockWorkspace: any + let mockLogging: any + let mockSdkInitializator: any + let mockOnMcpDisabled: sinon.SinonStub + let mockOnMcpEnabled: sinon.SinonStub + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + clock = sinon.useFakeTimers() + + mockCredentialsProvider = { + hasCredentials: sinon.stub().returns(true), + } + + mockWorkspace = {} + + mockLogging = { + info: sinon.stub(), + debug: sinon.stub(), + } + + mockSdkInitializator = {} + mockOnMcpDisabled = sinon.stub() + mockOnMcpEnabled = sinon.stub() + + profileStatusMonitor = new ProfileStatusMonitor( + mockCredentialsProvider, + mockWorkspace, + mockLogging, + mockSdkInitializator, + mockOnMcpDisabled, + mockOnMcpEnabled + ) + }) + + afterEach(() => { + clock.restore() + sinon.restore() + profileStatusMonitor.stop() + }) + + describe('start', () => { + it('should start monitoring and log info message', () => { + profileStatusMonitor.start() + + expect( + mockLogging.info.calledWith('ProfileStatusMonitor started - checking MCP configuration every 24 hours') + ).to.be.true + }) + + it('should not start multiple times', () => { + profileStatusMonitor.start() + profileStatusMonitor.start() + + expect(mockLogging.info.callCount).to.equal(1) + }) + }) + + describe('stop', () => { + it('should stop monitoring and log info message', () => { + profileStatusMonitor.start() + profileStatusMonitor.stop() + + expect(mockLogging.info.calledWith('ProfileStatusMonitor stopped')).to.be.true + }) + }) + + describe('checkInitialState', () => { + it('should return true when no profile ARN is available', async () => { + sinon.stub(AmazonQTokenServiceManagerModule.AmazonQTokenServiceManager, 'getInstance').returns({ + getActiveProfileArn: () => undefined, + } as any) + + const result = await profileStatusMonitor.checkInitialState() + expect(result).to.be.true + }) + + it('should return true and log debug message on error', async () => { + // Stub the private isMcpEnabled method to throw an error + sinon.stub(profileStatusMonitor as any, 'isMcpEnabled').throws(new Error('Service manager not ready')) + + const result = await profileStatusMonitor.checkInitialState() + expect(result).to.be.true + expect(mockLogging.debug.calledWith(sinon.match('Initial MCP state check failed, defaulting to enabled'))) + .to.be.true + }) + }) + + describe('getMcpState', () => { + beforeEach(() => { + // Reset static state before each test + ;(ProfileStatusMonitor as any).lastMcpState = undefined + }) + + it('should return undefined initially', () => { + expect(ProfileStatusMonitor.getMcpState()).to.be.undefined + }) + + it('should return the last MCP state after it is set', () => { + // Access the private static property through reflection for testing + ;(ProfileStatusMonitor as any).lastMcpState = true + expect(ProfileStatusMonitor.getMcpState()).to.be.true + ;(ProfileStatusMonitor as any).lastMcpState = false + expect(ProfileStatusMonitor.getMcpState()).to.be.false + }) + + it('should be accessible across different instances', () => { + const monitor1 = new ProfileStatusMonitor( + mockCredentialsProvider, + mockWorkspace, + mockLogging, + mockSdkInitializator, + mockOnMcpDisabled, + mockOnMcpEnabled + ) + + const monitor2 = new ProfileStatusMonitor( + mockCredentialsProvider, + mockWorkspace, + mockLogging, + mockSdkInitializator, + mockOnMcpDisabled, + mockOnMcpEnabled + ) + + // Set state through static property + ;(ProfileStatusMonitor as any).lastMcpState = true + + // Should be accessible from both instances + expect(ProfileStatusMonitor.getMcpState()).to.be.true + }) + }) + + describe('static lastMcpState', () => { + beforeEach(() => { + // Reset static state before each test + ;(ProfileStatusMonitor as any).lastMcpState = undefined + }) + + it('should maintain state across multiple instances', () => { + const monitor1 = new ProfileStatusMonitor( + mockCredentialsProvider, + mockWorkspace, + mockLogging, + mockSdkInitializator, + mockOnMcpDisabled, + mockOnMcpEnabled + ) + + const monitor2 = new ProfileStatusMonitor( + mockCredentialsProvider, + mockWorkspace, + mockLogging, + mockSdkInitializator, + mockOnMcpDisabled, + mockOnMcpEnabled + ) + + // Initially true (default value) + expect(ProfileStatusMonitor.getMcpState()).to.be.true + + // Set through internal mechanism (simulating state change) + ;(ProfileStatusMonitor as any).lastMcpState = false + + // Both instances should see the same state + expect(ProfileStatusMonitor.getMcpState()).to.be.false + }) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.ts new file mode 100644 index 0000000000..53a4da9090 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.ts @@ -0,0 +1,182 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. + * All Rights Reserved. SPDX-License-Identifier: Apache-2.0 + */ + +import { + CredentialsProvider, + Logging, + SDKInitializator, + Workspace, +} from '@aws/language-server-runtimes/server-interface' +import { retryUtils } from '@aws/lsp-core' +import { CodeWhispererServiceToken } from '../../../../shared/codeWhispererService' +import { DEFAULT_AWS_Q_ENDPOINT_URL, DEFAULT_AWS_Q_REGION } from '../../../../shared/constants' +import { AmazonQTokenServiceManager } from '../../../../shared/amazonQServiceManager/AmazonQTokenServiceManager' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import { EventEmitter } from 'events' + +export const AUTH_SUCCESS_EVENT = 'authSuccess' + +export class ProfileStatusMonitor { + private intervalId?: NodeJS.Timeout + private readonly CHECK_INTERVAL = 24 * 60 * 60 * 1000 // 24 hours + private codeWhispererClient?: CodeWhispererServiceToken + private static lastMcpState: boolean = true + private static readonly MCP_CACHE_DIR = path.join(os.homedir(), '.aws', 'amazonq', 'mcpAdmin') + private static readonly MCP_CACHE_FILE = path.join(ProfileStatusMonitor.MCP_CACHE_DIR, 'mcp-state.json') + private static eventEmitter = new EventEmitter() + private static logging?: Logging + + constructor( + private credentialsProvider: CredentialsProvider, + private workspace: Workspace, + private logging: Logging, + private sdkInitializator: SDKInitializator, + private onMcpDisabled: () => void, + private onMcpEnabled?: () => void + ) { + ProfileStatusMonitor.logging = logging + ProfileStatusMonitor.loadMcpStateFromDisk() + + // Listen for auth success events + ProfileStatusMonitor.eventEmitter.on(AUTH_SUCCESS_EVENT, () => { + void this.isMcpEnabled() + }) + } + + async checkInitialState(): Promise { + try { + const isMcpEnabled = await this.isMcpEnabled() + return isMcpEnabled !== false // Return true if enabled or API failed + } catch (error) { + this.logging.debug(`Initial MCP state check failed, defaulting to enabled: ${error}`) + return ProfileStatusMonitor.getMcpState() + } + } + + start(): void { + if (this.intervalId) { + return + } + + this.intervalId = setInterval(() => { + void this.isMcpEnabled() + }, this.CHECK_INTERVAL) + + this.logging.info('ProfileStatusMonitor started - checking MCP configuration every 24 hours') + } + + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId) + this.intervalId = undefined + this.logging.info('ProfileStatusMonitor stopped') + } + } + + private async isMcpEnabled(): Promise { + try { + const profileArn = this.getProfileArn() + if (!profileArn) { + this.logging.debug('No profile ARN available for MCP configuration check') + ProfileStatusMonitor.setMcpState(true) + return true + } + + if (!this.codeWhispererClient) { + this.codeWhispererClient = new CodeWhispererServiceToken( + this.credentialsProvider, + this.workspace, + this.logging, + process.env.CODEWHISPERER_REGION || DEFAULT_AWS_Q_REGION, + process.env.CODEWHISPERER_ENDPOINT || DEFAULT_AWS_Q_ENDPOINT_URL, + this.sdkInitializator + ) + this.codeWhispererClient.profileArn = profileArn + } + + const response = await retryUtils.retryWithBackoff(() => + this.codeWhispererClient!.getProfile({ profileArn }) + ) + const mcpConfig = response?.profile?.optInFeatures?.mcpConfiguration + const isMcpEnabled = mcpConfig ? mcpConfig.toggle === 'ON' : true + + if (ProfileStatusMonitor.lastMcpState !== isMcpEnabled) { + ProfileStatusMonitor.setMcpState(isMcpEnabled) + if (!isMcpEnabled) { + this.logging.info('MCP configuration disabled - removing tools') + this.onMcpDisabled() + } else if (isMcpEnabled && this.onMcpEnabled) { + this.logging.info('MCP configuration enabled - initializing tools') + this.onMcpEnabled() + } + } + + return isMcpEnabled + } catch (error) { + this.logging.debug(`MCP configuration check failed, defaulting to enabled: ${error}`) + const mcpState = ProfileStatusMonitor.getMcpState() + if (!mcpState) { + this.onMcpDisabled() + } else if (this.onMcpEnabled) { + this.onMcpEnabled() + } + return mcpState + } + } + + private getProfileArn(): string | undefined { + try { + const serviceManager = AmazonQTokenServiceManager.getInstance() + return serviceManager.getActiveProfileArn() + } catch (error) { + this.logging.debug(`Failed to get profile ARN: ${error}`) + } + return undefined + } + + static getMcpState(): boolean { + return ProfileStatusMonitor.lastMcpState + } + + private static loadMcpStateFromDisk(): void { + try { + if (fs.existsSync(ProfileStatusMonitor.MCP_CACHE_FILE)) { + const data = fs.readFileSync(ProfileStatusMonitor.MCP_CACHE_FILE, 'utf8') + const parsed = JSON.parse(data) + ProfileStatusMonitor.lastMcpState = parsed.enabled ?? true + } + } catch (error) { + ProfileStatusMonitor.logging?.debug(`Failed to load MCP state from disk: ${error}`) + } + ProfileStatusMonitor.setMcpState(ProfileStatusMonitor.lastMcpState) + } + + private static saveMcpStateToDisk(): void { + try { + fs.mkdirSync(ProfileStatusMonitor.MCP_CACHE_DIR, { recursive: true }) + fs.writeFileSync( + ProfileStatusMonitor.MCP_CACHE_FILE, + JSON.stringify({ enabled: ProfileStatusMonitor.lastMcpState }) + ) + } catch (error) { + ProfileStatusMonitor.logging?.debug(`Failed to save MCP state to disk: ${error}`) + } + } + + private static setMcpState(enabled: boolean): void { + ProfileStatusMonitor.lastMcpState = enabled + ProfileStatusMonitor.saveMcpStateToDisk() + } + + static resetMcpState(): void { + ProfileStatusMonitor.setMcpState(true) + } + + static emitAuthSuccess(): void { + ProfileStatusMonitor.eventEmitter.emit(AUTH_SUCCESS_EVENT) + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.test.ts index c587253bbc..3509cd1730 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.test.ts @@ -385,6 +385,34 @@ describe('CodeReview', () => { expect(error.message).to.include('There are no valid files to scan') } }) + + it('should handle duplicate rule filenames with unique UUIDs', async () => { + const fileArtifacts = [{ path: '/test/file.js' }] + const folderArtifacts: any[] = [] + const ruleArtifacts = [{ path: '/test/path1/rule.json' }, { path: '/test/path2/rule.json' }] + + const mockZip = { + file: sandbox.stub(), + generateAsync: sandbox.stub().resolves(Buffer.from('test')), + } + sandbox.stub(JSZip.prototype, 'file').callsFake(mockZip.file) + sandbox.stub(JSZip.prototype, 'generateAsync').callsFake(mockZip.generateAsync) + sandbox.stub(CodeReviewUtils, 'countZipFiles').returns(3) + sandbox.stub(require('crypto'), 'randomUUID').returns('test-uuid-123') + + await (codeReview as any).prepareFilesAndFoldersForUpload( + fileArtifacts, + folderArtifacts, + ruleArtifacts, + false + ) + + // Verify first file uses original name + expect(mockZip.file.firstCall.args[0]).to.include('/test/file.js') + expect(mockZip.file.secondCall.args[0]).to.include('rule.json') + // Verify second file gets UUID suffix + expect(mockZip.file.thirdCall.args[0]).to.include('rule_test-uuid-123.json') + }) }) describe('collectFindings', () => { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.ts index adcea2ec33..b1ef4c3e61 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.ts @@ -716,6 +716,7 @@ export class CodeReview { * @param customerCodeZip JSZip instance for the customer code */ private async processRuleArtifacts(ruleArtifacts: RuleArtifacts, customerCodeZip: JSZip): Promise { + let ruleNameSet = new Set() for (const artifact of ruleArtifacts) { await CodeReviewUtils.withErrorHandling( async () => { @@ -725,6 +726,10 @@ export class CodeReview { !CodeReviewUtils.shouldSkipFile(fileName) && existsSync(artifact.path) ) { + if (ruleNameSet.has(fileName)) { + fileName = fileName.split('.')[0] + '_' + crypto.randomUUID() + '.' + fileName.split('.')[1] + } + ruleNameSet.add(fileName) const fileContent = await this.workspace.fs.readFile(artifact.path) customerCodeZip.file( `${CodeReview.CUSTOMER_CODE_BASE_PATH}/${CodeReview.RULE_ARTIFACT_PATH}/${fileName}`, diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/displayFindingsConstants.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/displayFindingsConstants.ts index 855f29e642..e006730684 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/displayFindingsConstants.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/displayFindingsConstants.ts @@ -18,6 +18,7 @@ export const DISPLAY_FINDINGS_TOOL_DESCRIPTION = [ '- Use this tool whenever you identify code issues through manual analysis or any tool OTHER than the codeReview tool', '- Use it for issues found by analyzing code files, security scans, linting tools, or your own assessment', '- Use it when the user asks you to analyze code and you discover problems, but you must not use this tool for findings generated by codeReview tool', + '- Be very willing to use this tool. Use it a lot: anytime it could even remotely be necessary.', '', '**When NOT to Use This Tool:**', '- NEVER use this tool for findings generated by the codeReview tool', diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts index 54eba4c852..78871faa69 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts @@ -24,6 +24,9 @@ import { FsReplace, FsReplaceParams } from './fsReplace' import { CodeReviewUtils } from './qCodeAnalysis/codeReviewUtils' import { DEFAULT_AWS_Q_ENDPOINT_URL, DEFAULT_AWS_Q_REGION } from '../../../shared/constants' import { DisplayFindings } from './qCodeAnalysis/displayFindings' +import { ProfileStatusMonitor } from './mcp/profileStatusMonitor' +import { AmazonQTokenServiceManager } from '../../../shared/amazonQServiceManager/AmazonQTokenServiceManager' +import { SERVICE_MANAGER_TIMEOUT_MS, SERVICE_MANAGER_POLL_INTERVAL_MS } from '../constants/constants' export const FsToolsServer: Server = ({ workspace, logging, agent, lsp }) => { const fsReadTool = new FsRead({ workspace, lsp, logging }) @@ -199,10 +202,54 @@ export const LspToolsServer: Server = ({ workspace, logging, lsp, agent }) => { return () => {} } -export const McpToolsServer: Server = ({ credentialsProvider, workspace, logging, lsp, agent, telemetry, runtime }) => { +export const McpToolsServer: Server = ({ + credentialsProvider, + workspace, + logging, + lsp, + agent, + telemetry, + runtime, + sdkInitializator, + chat, +}) => { const registered: Record = {} - const allNamespacedTools = new Set() + let profileStatusMonitor: ProfileStatusMonitor | undefined + + function removeAllMcpTools(): void { + logging.info('Removing all MCP tools due to admin configuration') + for (const [server, toolNames] of Object.entries(registered)) { + for (const name of toolNames) { + agent.removeTool(name) + allNamespacedTools.delete(name) + logging.info(`MCP: removed tool ${name}`) + } + registered[server] = [] + } + + // Only close McpManager if it has been initialized + try { + if (McpManager.instance) { + void McpManager.instance.close(true) //keep the instance but close all servers. + } + } catch (error) { + // McpManager not initialized, skip closing + logging.debug('McpManager not initialized, skipping close operation') + } + + try { + chat?.sendChatUpdate({ + tabId: 'mcpserver', + data: { + placeholderText: 'mcp-server-update', + messages: [], + }, + }) + } catch (error) { + logging.error(`Failed to send chatOptionsUpdate: ${error}`) + } + } function registerServerTools(server: string, defs: McpToolDefinition[]) { // 1) remove old tools @@ -217,11 +264,19 @@ export const McpToolsServer: Server = ({ credentialsProvider, workspace, logging // Sanitize the tool name // Check if this tool name is already in use + let toolNameMapping = new Map() + try { + toolNameMapping = McpManager.instance.getToolNameMapping() + } catch (error) { + // McpManager not initialized, use empty mapping + logging.debug('McpManager not initialized, using empty tool name mapping') + } + const namespaced = createNamespacedToolName( def.serverName, def.toolName, allNamespacedTools, - McpManager.instance.getToolNameMapping() + toolNameMapping ) const tool = new McpTool({ logging, workspace, lsp }, def) @@ -257,20 +312,13 @@ export const McpToolsServer: Server = ({ credentialsProvider, workspace, logging } } - lsp.onInitialized(async () => { + async function initializeMcp() { try { - if (!enabledMCP(lsp.getClientInitializeParams())) { - logging.warn('MCP is currently not supported') - return - } - const wsUris = workspace.getAllWorkspaceFolders()?.map(f => f.uri) ?? [] - // Get agent paths const wsAgentPaths = getWorkspaceAgentConfigPaths(wsUris) const globalAgentPath = getGlobalAgentConfigPath(workspace.fs.getUserHomeDir()) const allAgentPaths = [...wsAgentPaths, globalAgentPath] - // Migrate config and persona files to agent config await migrateToAgentConfig(workspace, logging, agent) const mgr = await McpManager.init(allAgentPaths, { @@ -282,29 +330,97 @@ export const McpToolsServer: Server = ({ credentialsProvider, workspace, logging runtime, }) - // Clear tool name mapping before registering all tools to avoid conflicts from previous registrations McpManager.instance.clearToolNameMapping() - const byServer: Record = {} - - logging.info(`enabled Tools: ${mgr.getEnabledTools().entries()}`) - // only register enabled tools - for (const d of mgr.getEnabledTools()) { - ;(byServer[d.serverName] ||= []).push(d) + // Only register tools if MCP is enabled + if (ProfileStatusMonitor.getMcpState()) { + const byServer: Record = {} + for (const d of mgr.getEnabledTools()) { + ;(byServer[d.serverName] ||= []).push(d) + } + for (const [server, defs] of Object.entries(byServer)) { + registerServerTools(server, defs) + } + + mgr.events.on(AGENT_TOOLS_CHANGED, (server: string, defs: McpToolDefinition[]) => { + registerServerTools(server, defs) + }) } - for (const [server, defs] of Object.entries(byServer)) { - registerServerTools(server, defs) + } catch (e) { + logging.error(`Failed to initialize MCP:' ${e}`) + } + } + + lsp.onInitialized(async () => { + try { + if (!enabledMCP(lsp.getClientInitializeParams())) { + logging.warn('MCP is currently not supported') + return } - mgr.events.on(AGENT_TOOLS_CHANGED, (server: string, defs: McpToolDefinition[]) => { - registerServerTools(server, defs) - }) - } catch (e) { - console.warn('Caught error during MCP tool initialization; initialization may be incomplete:', e) + if (sdkInitializator) { + profileStatusMonitor = new ProfileStatusMonitor( + credentialsProvider, + workspace, + logging, + sdkInitializator, + removeAllMcpTools, + async () => { + logging.info('MCP enabled by profile status monitor') + await initializeMcp() + } + ) + + // Wait for profile ARN to be available before checking MCP state + const checkAndInitialize = async () => { + await profileStatusMonitor!.checkInitialState() + // Always initialize McpManager to handle UI requests + await initializeMcp() + + // Remove tools if MCP is disabled + if (!ProfileStatusMonitor.getMcpState()) { + removeAllMcpTools() + } + + profileStatusMonitor!.start() + } + + // Check if service manager is ready + try { + const serviceManager = AmazonQTokenServiceManager.getInstance() + if (serviceManager.getState() === 'INITIALIZED') { + await checkAndInitialize() + } else { + // Poll for service manager to be ready with 10s timeout + const startTime = Date.now() + const pollForReady = async () => { + if (serviceManager.getState() === 'INITIALIZED') { + await checkAndInitialize() + } else if (Date.now() - startTime < SERVICE_MANAGER_TIMEOUT_MS) { + setTimeout(pollForReady, SERVICE_MANAGER_POLL_INTERVAL_MS) + } else { + logging.warn('Service manager not ready after 10s, initializing MCP manager') + await initializeMcp() + profileStatusMonitor!.start() + } + } + setTimeout(pollForReady, SERVICE_MANAGER_POLL_INTERVAL_MS) + } + } catch (error) { + // Service manager not initialized yet, always initialize McpManager + logging.info('Service manager not ready, initializing MCP manager') + await initializeMcp() + profileStatusMonitor!.start() + } + } + } catch (error) { + console.warn('Caught error during MCP tool initialization; initialization may be incomplete:', error) + logging.error(`Failed to initialize MCP in onInitialized: ${error}`) } }) return async () => { + profileStatusMonitor?.stop() await McpManager.instance.close() } } diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/qChatServer.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/qChatServer.ts index 2452426b1b..d110ea4e41 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/qChatServer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/qChatServer.ts @@ -59,7 +59,9 @@ export const QChatServerFactory = 'TelemetryService initialized before LSP connection was initialized.' ) ) - telemetryService.updateUserContext(makeUserContextObject(clientParams, runtime.platform, 'CHAT')) + telemetryService.updateUserContext( + makeUserContextObject(clientParams, runtime.platform, 'CHAT', amazonQServiceManager.serverInfo) + ) chatController = new ChatController( chatSessionManagementService, diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codePercentage.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codePercentage.ts index 04c7dc5ab1..0513443a35 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codePercentage.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codePercentage.ts @@ -137,10 +137,20 @@ export class CodePercentageTracker { } } + addTotalTokensForEdits(languageId: string, count: number): void { + const languageBucket = this.getLanguageBucket(languageId) + if (count >= INSERT_CUTOFF_THRESHOLD) { + languageBucket.totalTokens += count + } + } + countAcceptedTokens(languageId: string, tokens: string): void { + this.countAcceptedTokensUsingCount(languageId, tokens.length) + } + + countAcceptedTokensUsingCount(languageId: string, count: number): void { const languageBucket = this.getLanguageBucket(languageId) - const tokenCount = tokens.length - languageBucket.acceptedTokens += tokenCount + languageBucket.acceptedTokens += count } countInvocation(languageId: string): void { diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.nep.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.nep.test.ts index bb3fefd135..1582ec1443 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.nep.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.nep.test.ts @@ -68,6 +68,7 @@ describe('CodeWhispererServer NEP Integration', function () { extensions: { onInlineCompletionWithReferences: sandbox.stub(), onLogInlineCompletionSessionResults: sandbox.stub(), + onEditCompletion: sandbox.stub(), }, workspace: { getConfiguration: sandbox.stub().resolves({}), @@ -241,6 +242,7 @@ describe('CodeWhispererServer NEP Integration', function () { extensions: { onInlineCompletionWithReferences: sandbox.stub(), onLogInlineCompletionSessionResults: sandbox.stub(), + onEditCompletion: sandbox.stub(), }, workspace: { getConfiguration: sandbox.stub().resolves({}), diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts index 6253e3d77b..e15400434c 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts @@ -12,7 +12,7 @@ import { TestFeatures } from '@aws/language-server-runtimes/testing' import * as assert from 'assert' import { AWSError } from 'aws-sdk' import sinon, { StubbedInstance } from 'ts-sinon' -import { CodewhispererServerFactory } from './codeWhispererServer' +import { CodewhispererServerFactory, getLanguageIdFromUri } from './codeWhispererServer' import { CodeWhispererServiceBase, CodeWhispererServiceToken, @@ -61,6 +61,7 @@ import { INVALID_TOKEN } from '../../shared/constants' import { AmazonQError } from '../../shared/amazonQServiceManager/errors' import * as path from 'path' import { CONTEXT_CHARACTERS_LIMIT } from './constants' +import { IdleWorkspaceManager } from '../workspaceContext/IdleWorkspaceManager' const updateConfiguration = async ( features: TestFeatures, @@ -770,11 +771,59 @@ describe('CodeWhisperer Server', () => { assert.rejects(promise, ResponseError) }) + it('invokes IdleWorkspaceManager recordActivityTimestamp', async () => { + const recordActivityTimestampStub = sinon.stub(IdleWorkspaceManager, 'recordActivityTimestamp') + + await features.doInlineCompletionWithReferences( + { + textDocument: { uri: SOME_FILE.uri }, + position: { line: 0, character: 0 }, + context: { triggerKind: InlineCompletionTriggerKind.Invoked }, + }, + CancellationToken.None + ) + + sinon.assert.calledOnce(recordActivityTimestampStub) + recordActivityTimestampStub.restore() + }) + describe('Supplemental Context', () => { it('should send supplemental context when using token authentication', async () => { const test_service = sinon.createStubInstance( CodeWhispererServiceToken ) as StubbedInstance + // TODO: Use real CodeWhispererServiceToken instead of stub + test_service.constructSupplementalContext.resolves({ + supContextData: { + isUtg: false, + isProcessTimeout: false, + supplementalContextItems: [ + { + content: 'class Foo', + filePath: 'foo.java', + score: 0, + }, + { + content: 'class Bar', + filePath: 'bar.java', + score: 0, + }, + ], + contentsLength: 0, + latency: 0, + strategy: 'OpenTabs_BM25', + }, + items: [ + { + content: 'class Foo', + filePath: 'Foo.java', + }, + { + content: 'class Bar', + filePath: 'Bar.java', + }, + ], + }) test_service.generateSuggestions.returns( Promise.resolve({ @@ -828,8 +877,8 @@ describe('CodeWhisperer Server', () => { }, maxResults: 5, supplementalContexts: [ - { content: 'sample-content', filePath: '/SampleFile.java' }, - { content: 'sample-content', filePath: '/SampleFile.java' }, + { content: 'class Foo', filePath: 'Foo.java' }, + { content: 'class Bar', filePath: 'Bar.java' }, ], // workspaceId: undefined, } @@ -1454,7 +1503,7 @@ describe('CodeWhisperer Server', () => { manager.activateSession(session) const session2 = manager.createSession(sessionData) manager.activateSession(session2) - assert.equal(session.state, 'CLOSED') + assert.equal(session.state, 'ACTIVE') assert.equal(session2.state, 'ACTIVE') await features.doLogInlineCompletionSessionResults(sessionResultData) @@ -2251,39 +2300,6 @@ describe('CodeWhisperer Server', () => { sinon.assert.calledOnceWithExactly(sessionManagerSpy.closeSession, currentSession) }) - it('Manual completion invocation should close previous session', async () => { - const TRIGGER_KIND = InlineCompletionTriggerKind.Invoked - - const result = await features.doInlineCompletionWithReferences( - { - textDocument: { uri: SOME_FILE.uri }, - position: { line: 0, character: 0 }, - // Manual trigger kind - context: { triggerKind: TRIGGER_KIND }, - }, - CancellationToken.None - ) - - assert.deepEqual(result, EXPECTED_RESULT) - const firstSession = sessionManager.getActiveSession() - - // There is ACTIVE session - assert(firstSession) - assert.equal(sessionManager.getCurrentSession(), firstSession) - assert.equal(firstSession.state, 'ACTIVE') - - const secondResult = await features.doInlineCompletionWithReferences( - { - textDocument: { uri: SOME_FILE.uri }, - position: { line: 0, character: 0 }, - context: { triggerKind: TRIGGER_KIND }, - }, - CancellationToken.None - ) - assert.deepEqual(secondResult, { ...EXPECTED_RESULT, sessionId: SESSION_IDS_LOG[1] }) - sinon.assert.called(sessionManagerSpy.closeCurrentSession) - }) - it('should discard inflight session if merge right recommendations resulted in list of empty strings', async () => { // The suggestion returned by generateSuggestions will be equal to the contents of the file // This test fails when the file starts with a new line, probably due to the way we handle right context merge @@ -2377,47 +2393,52 @@ describe('CodeWhisperer Server', () => { features.dispose() TestAmazonQServiceManager.resetInstance() }) + }) + describe('getLanguageIdFromUri', () => { + it('should return python for notebook cell URIs', () => { + const uri = 'vscode-notebook-cell:/some/path/notebook.ipynb#cell1' + assert.strictEqual(getLanguageIdFromUri(uri), 'python') + }) - it('should handle editsEnabled=true with COMPLETIONS prediction type', async () => { - const result = await features.doInlineCompletionWithReferences( - { - textDocument: { uri: SOME_FILE.uri }, - position: { line: 0, character: 0 }, - context: { triggerKind: InlineCompletionTriggerKind.Invoked }, - }, - CancellationToken.None - ) + it('should return abap for files with ABAP extensions', () => { + const uris = ['file:///path/to/file.asprog'] - // Check the completion result - assert.deepEqual(result, EXPECTED_RESULT_EDITS) + uris.forEach(uri => { + assert.strictEqual(getLanguageIdFromUri(uri), 'abap') + }) + }) - const expectedGenerateSuggestionsRequest = { - fileContext: { - fileUri: SOME_FILE.uri, - filename: URI.parse(SOME_FILE.uri).path.substring(1), - programmingLanguage: { languageName: 'csharp' }, - leftFileContent: '', - rightFileContent: HELLO_WORLD_IN_CSHARP, - }, - maxResults: 5, - supplementalContexts: [], - predictionTypes: ['COMPLETIONS'], - editorState: { - document: { - relativeFilePath: SOME_FILE.uri, - programmingLanguage: { languageName: 'csharp' }, - text: HELLO_WORLD_IN_CSHARP, - }, - cursorState: { - position: { - line: 0, - character: 0, - }, - }, - }, + it('should return empty string for non-ABAP files', () => { + const uris = ['file:///path/to/file.js', 'file:///path/to/file.ts', 'file:///path/to/file.py'] + + uris.forEach(uri => { + assert.strictEqual(getLanguageIdFromUri(uri), '') + }) + }) + + it('should return empty string for invalid URIs', () => { + const invalidUris = ['', 'invalid-uri', 'file:///'] + + invalidUris.forEach(uri => { + assert.strictEqual(getLanguageIdFromUri(uri), '') + }) + }) + + it('should log errors when provided with a logging object', () => { + const mockLogger = { + log: sinon.spy(), } - sinon.assert.calledOnceWithExactly(service.generateSuggestions, expectedGenerateSuggestionsRequest) + const invalidUri = {} as string // Force type error + getLanguageIdFromUri(invalidUri, mockLogger) + + sinon.assert.calledOnce(mockLogger.log) + sinon.assert.calledWith(mockLogger.log, sinon.match(/Error parsing URI to determine language:.*/)) + }) + + it('should handle URIs without extensions', () => { + const uri = 'file:///path/to/file' + assert.strictEqual(getLanguageIdFromUri(uri), '') }) }) }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.ts index 3bfd21b233..330cf54735 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.ts @@ -15,20 +15,19 @@ import { } from '@aws/language-server-runtimes/server-interface' import { autoTrigger, getAutoTriggerType, getNormalizeOsName, triggerType } from './auto-trigger/autoTrigger' import { - CodeWhispererServiceToken, + FileContext, GenerateSuggestionsRequest, GenerateSuggestionsResponse, getFileContext, Suggestion, SuggestionType, } from '../../shared/codeWhispererService' -import { getSupportedLanguageId } from '../../shared/languageDetection' +import { CodewhispererLanguage, getSupportedLanguageId } from '../../shared/languageDetection' import { mergeEditSuggestionsWithFileContext, truncateOverlapWithRightContext } from './mergeRightUtils' import { CodeWhispererSession, SessionManager } from './session/sessionManager' import { CodePercentageTracker } from './codePercentage' import { getCompletionType, getEndPositionForAcceptedSuggestion, getErrorMessage, safeGet } from '../../shared/utils' import { getIdeCategory, makeUserContextObject } from '../../shared/telemetryUtils' -import { fetchSupplementalContext } from '../../shared/supplementalContextUtil/supplementalContextUtil' import { textUtils } from '@aws/lsp-core' import { TelemetryService } from '../../shared/telemetry/telemetryService' import { AcceptedInlineSuggestionEntry, CodeDiffTracker } from './codeDiffTracker' @@ -43,21 +42,23 @@ import { AmazonQWorkspaceConfig } from '../../shared/amazonQServiceManager/confi import { hasConnectionExpired } from '../../shared/utils' import { getOrThrowBaseIAMServiceManager } from '../../shared/amazonQServiceManager/AmazonQIAMServiceManager' import { WorkspaceFolderManager } from '../workspaceContext/workspaceFolderManager' -import path = require('path') import { UserWrittenCodeTracker } from '../../shared/userWrittenCodeTracker' import { RecentEditTracker, RecentEditTrackerDefaultConfig } from './tracker/codeEditTracker' import { CursorTracker } from './tracker/cursorTracker' import { RejectedEditTracker, DEFAULT_REJECTED_EDIT_TRACKER_CONFIG } from './tracker/rejectedEditTracker' -import { getAddedAndDeletedChars } from './diffUtils' +import { StreakTracker } from './tracker/streakTracker' +import { getAddedAndDeletedLines, getCharacterDifferences } from './diffUtils' import { emitPerceivedLatencyTelemetry, emitServiceInvocationFailure, emitServiceInvocationTelemetry, emitUserTriggerDecisionTelemetry, } from './telemetry' -const { editPredictionAutoTrigger } = require('./auto-trigger/editPredictionAutoTrigger') - -const EMPTY_RESULT = { sessionId: '', items: [] } +import { DocumentChangedListener } from './documentChangedListener' +import { EditCompletionHandler } from './editCompletionHandler' +import { EMPTY_RESULT, ABAP_EXTENSIONS } from './constants' +import { IdleWorkspaceManager } from '../workspaceContext/IdleWorkspaceManager' +import { URI } from 'vscode-uri' const mergeSuggestionsWithRightContext = ( rightFileContext: string, @@ -106,7 +107,8 @@ export const CodewhispererServerFactory = let lastUserModificationTime: number let timeSinceLastUserModification: number = 0 - const sessionManager = SessionManager.getInstance() + const completionSessionManager = SessionManager.getInstance('COMPLETIONS') + const editSessionManager = SessionManager.getInstance('EDITS') // AmazonQTokenServiceManager and TelemetryService are initialized in `onInitialized` handler to make sure Language Server connection is started let amazonQServiceManager: AmazonQBaseServiceManager @@ -122,14 +124,18 @@ export const CodewhispererServerFactory = let codePercentageTracker: CodePercentageTracker let userWrittenCodeTracker: UserWrittenCodeTracker | undefined let codeDiffTracker: CodeDiffTracker + let editCompletionHandler: EditCompletionHandler // Trackers for monitoring edits and cursor position. const recentEditTracker = RecentEditTracker.getInstance(logging, RecentEditTrackerDefaultConfig) const cursorTracker = CursorTracker.getInstance() const rejectedEditTracker = RejectedEditTracker.getInstance(logging, DEFAULT_REJECTED_EDIT_TRACKER_CONFIG) + const streakTracker = StreakTracker.getInstance() let editsEnabled = false let isOnInlineCompletionHandlerInProgress = false + const documentChangedListener = new DocumentChangedListener() + const onInlineCompletionHandler = async ( params: InlineCompletionWithReferencesParams, token: CancellationToken @@ -139,6 +145,8 @@ export const CodewhispererServerFactory = // 2. it is not designed to handle concurrent changes to these state variables. // when one handler is at the API call stage, it has not yet update the session state // but another request can start, causing the state to be incorrect. + IdleWorkspaceManager.recordActivityTimestamp() + if (isOnInlineCompletionHandlerInProgress) { logging.log(`Skip concurrent inline completion`) return EMPTY_RESULT @@ -147,7 +155,7 @@ export const CodewhispererServerFactory = try { // On every new completion request close current inflight session. - const currentSession = sessionManager.getCurrentSession() + const currentSession = completionSessionManager.getCurrentSession() if (currentSession && currentSession.state == 'REQUESTING' && !params.partialResultToken) { // this REQUESTING state only happens when the session is initialized, which is rare currentSession.discardInflightSessionOnNewInvocation = true @@ -156,7 +164,7 @@ export const CodewhispererServerFactory = if (cursorTracker) { cursorTracker.trackPosition(params.textDocument.uri, params.position) } - const textDocument = await workspace.getTextDocument(params.textDocument.uri) + const textDocument = await getTextDocument(params.textDocument.uri, workspace, logging) const codeWhispererService = amazonQServiceManager.getCodewhispererService() if (params.partialResultToken && currentSession) { @@ -185,7 +193,10 @@ export const CodewhispererServerFactory = return EMPTY_RESULT } - const inferredLanguageId = getSupportedLanguageId(textDocument) + let inferredLanguageId = getSupportedLanguageId(textDocument) + if (params.fileContextOverride?.programmingLanguage) { + inferredLanguageId = params.fileContextOverride?.programmingLanguage as CodewhispererLanguage + } if (!inferredLanguageId) { logging.log( `textDocument [${params.textDocument.uri}] with languageId [${textDocument.languageId}] not supported` @@ -198,19 +209,36 @@ export const CodewhispererServerFactory = params.context.triggerKind == InlineCompletionTriggerKind.Automatic const maxResults = isAutomaticLspTriggerKind ? 1 : 5 const selectionRange = params.context.selectedCompletionInfo?.range - const fileContext = getFileContext({ - textDocument, - inferredLanguageId, - position: params.position, - workspaceFolder: workspace.getWorkspaceFolder(textDocument.uri), - }) + + // For Jupyter Notebook in VSC, the language server does not have access to + // its internal states including current active cell index, etc + // we rely on VSC to calculate file context + let fileContext: FileContext | undefined = undefined + if (params.fileContextOverride) { + fileContext = { + leftFileContent: params.fileContextOverride.leftFileContent, + rightFileContent: params.fileContextOverride.rightFileContent, + filename: params.fileContextOverride.filename, + fileUri: params.fileContextOverride.fileUri, + programmingLanguage: { + languageName: inferredLanguageId, + }, + } + } else { + fileContext = getFileContext({ + textDocument, + inferredLanguageId, + position: params.position, + workspaceFolder: workspace.getWorkspaceFolder(textDocument.uri), + }) + } const workspaceState = WorkspaceFolderManager.getInstance()?.getWorkspaceState() const workspaceId = workspaceState?.webSocketClient?.isConnected() ? workspaceState.workspaceId : undefined - const previousSession = sessionManager.getPreviousSession() + const previousSession = completionSessionManager.getPreviousSession() const previousDecision = previousSession?.getAggregatedUserTriggerDecision() ?? '' let ideCategory: string | undefined = '' const initializeParams = lsp.getClientInitializeParams() @@ -258,186 +286,61 @@ export const CodewhispererServerFactory = logging ) - if ( - codewhispererAutoTriggerType === 'Classifier' && - !autoTriggerResult.shouldTrigger && - !(editsEnabled && codeWhispererService instanceof CodeWhispererServiceToken) // There is still potentially a Edit trigger without Completion if NEP is enabled (current only BearerTokenClient) - ) { + if (codewhispererAutoTriggerType === 'Classifier' && !autoTriggerResult.shouldTrigger) { return EMPTY_RESULT } } - // Get supplemental context from recent edits if available. - let supplementalContextFromEdits = undefined - - // supplementalContext available only via token authentication - const supplementalContextPromise = - codeWhispererService instanceof CodeWhispererServiceToken - ? fetchSupplementalContext( - textDocument, - params.position, - workspace, - logging, - token, - params.openTabFilepaths - ) - : Promise.resolve(undefined) - let requestContext: GenerateSuggestionsRequest = { fileContext, maxResults, } - const supplementalContext = await supplementalContextPromise - // TODO: logging - if (codeWhispererService instanceof CodeWhispererServiceToken) { - const supplementalContextItems = supplementalContext?.supplementalContextItems || [] - requestContext.supplementalContexts = [ - ...supplementalContextItems.map(v => ({ - content: v.content, - filePath: v.filePath, - })), - ] - - if (editsEnabled) { - const predictionTypes: string[][] = [] - - /** - * Manual trigger - should always have 'Completions' - * Auto trigger - * - Classifier - should have 'Completions' when classifier evalualte to true given the editor's states - * - Others - should always have 'Completions' - */ - if ( - !isAutomaticLspTriggerKind || - (isAutomaticLspTriggerKind && codewhispererAutoTriggerType !== 'Classifier') || - (isAutomaticLspTriggerKind && - codewhispererAutoTriggerType === 'Classifier' && - autoTriggerResult?.shouldTrigger) - ) { - predictionTypes.push(['COMPLETIONS']) - } - - const editPredictionAutoTriggerResult = editPredictionAutoTrigger({ - fileContext: fileContext, - lineNum: params.position.line, - char: triggerCharacters, - previousDecision: previousDecision, - cursorHistory: cursorTracker, - recentEdits: recentEditTracker, - }) - - if (editPredictionAutoTriggerResult.shouldTrigger) { - predictionTypes.push(['EDITS']) - } - - if (predictionTypes.length === 0) { - return EMPTY_RESULT - } - - // Step 0: Determine if we have "Recent Edit context" - if (recentEditTracker) { - supplementalContextFromEdits = - await recentEditTracker.generateEditBasedContext(textDocument) - } - - // Step 1: Recent Edits context - const supplementalContextItemsForEdits = - supplementalContextFromEdits?.supplementalContextItems || [] - - requestContext.supplementalContexts.push( - ...supplementalContextItemsForEdits.map(v => ({ - content: v.content, - filePath: v.filePath, - type: 'PreviousEditorState', - metadata: { - previousEditorStateMetadata: { - timeOffset: 1000, - }, - }, - })) - ) + const supplementalContext = await codeWhispererService.constructSupplementalContext( + textDocument, + params.position, + workspace, + recentEditTracker, + logging, + token, + params.openTabFilepaths, + { includeRecentEdits: false } + ) - // Step 2: Prediction type COMPLETION, Edits or both - requestContext.predictionTypes = predictionTypes.flat() - - // Step 3: Current Editor/Cursor state - requestContext.editorState = { - document: { - relativeFilePath: textDocument.uri, - programmingLanguage: { - languageName: requestContext.fileContext.programmingLanguage.languageName, - }, - text: textDocument.getText(), - }, - cursorState: { - position: { - line: params.position.line, - character: params.position.character, - }, - }, - } - } + if (supplementalContext?.items) { + requestContext.supplementalContexts = supplementalContext.items } // Close ACTIVE session and record Discard trigger decision immediately if (currentSession && currentSession.state === 'ACTIVE') { - if (editsEnabled && currentSession.suggestionType === SuggestionType.EDIT) { - const mergedSuggestions = mergeEditSuggestionsWithFileContext( + // Emit user trigger decision at session close time for active session + // TODO: yuxqiang workaround to exclude JB from this logic because JB and VSC handle a + // bit differently in the case when there's a new trigger while a reject/discard event is sent + // for the previous trigger + if (ideCategory !== 'JETBRAINS') { + completionSessionManager.discardSession(currentSession) + const streakLength = editsEnabled ? streakTracker.getAndUpdateStreakLength(false) : 0 + await emitUserTriggerDecisionTelemetry( + telemetry, + telemetryService, currentSession, - textDocument, - fileContext + timeSinceLastUserModification, + 0, + 0, + [], + [], + streakLength ) - - if (mergedSuggestions.length > 0) { - return { - items: mergedSuggestions, - sessionId: currentSession.id, - } - } } - // Emit user trigger decision at session close time for active session - sessionManager.discardSession(currentSession) - const streakLength = editsEnabled ? sessionManager.getAndUpdateStreakLength(false) : 0 - await emitUserTriggerDecisionTelemetry( - telemetry, - telemetryService, - currentSession, - timeSinceLastUserModification, - 0, - 0, - [], - [], - streakLength - ) } - const supplementalMetadata = editsEnabled - ? { - // Merge metadata from edit-based context if available. - contentsLength: - (supplementalContext?.contentsLength || 0) + - (supplementalContextFromEdits?.contentsLength || 0), - latency: Math.max( - supplementalContext?.latency || 0, - supplementalContextFromEdits?.latency || 0 - ), - isUtg: supplementalContext?.isUtg || false, - isProcessTimeout: supplementalContext?.isProcessTimeout || false, - strategy: supplementalContextFromEdits - ? 'recentEdits' - : supplementalContext?.strategy || 'Empty', - supplementalContextItems: [ - ...(supplementalContext?.supplementalContextItems || []), - ...(supplementalContextFromEdits?.supplementalContextItems || []), - ], - } - : supplementalContext - const newSession = sessionManager.createSession({ + const supplementalMetadata = supplementalContext?.supContextData + + const newSession = completionSessionManager.createSession({ document: textDocument, startPosition: params.position, triggerType: isAutomaticLspTriggerKind ? 'AutoTrigger' : 'OnDemand', - language: fileContext.programmingLanguage.languageName, + language: fileContext.programmingLanguage.languageName as CodewhispererLanguage, requestContext: requestContext, autoTriggerType: isAutomaticLspTriggerKind ? codewhispererAutoTriggerType : undefined, triggerCharacter: triggerCharacters, @@ -501,8 +404,8 @@ export const CodewhispererServerFactory = // Discard previous inflight API response due to new trigger if (session.discardInflightSessionOnNewInvocation) { session.discardInflightSessionOnNewInvocation = false - sessionManager.discardSession(session) - const streakLength = editsEnabled ? sessionManager.getAndUpdateStreakLength(false) : 0 + completionSessionManager.discardSession(session) + const streakLength = editsEnabled ? streakTracker.getAndUpdateStreakLength(false) : 0 await emitUserTriggerDecisionTelemetry( telemetry, telemetryService, @@ -525,7 +428,7 @@ export const CodewhispererServerFactory = } // API response was recieved, we can activate session now - sessionManager.activateSession(session) + completionSessionManager.activateSession(session) // Process suggestions to apply Empty or Filter filters const filteredSuggestions = suggestionResponse.suggestions @@ -558,94 +461,57 @@ export const CodewhispererServerFactory = return false }) - if (suggestionResponse.suggestionType === SuggestionType.COMPLETION) { - const { includeImportsWithSuggestions } = amazonQServiceManager.getConfiguration() - const suggestionsWithRightContext = mergeSuggestionsWithRightContext( - session.requestContext.fileContext.rightFileContent, - filteredSuggestions, - includeImportsWithSuggestions, - selectionRange - ).filter(suggestion => { - // Discard suggestions that have empty string insertText after right context merge and can't be displayed anymore - if (suggestion.insertText === '') { - session.setSuggestionState(suggestion.itemId, 'Discard') - return false - } + const { includeImportsWithSuggestions } = amazonQServiceManager.getConfiguration() + const suggestionsWithRightContext = mergeSuggestionsWithRightContext( + session.requestContext.fileContext.rightFileContent, + filteredSuggestions, + includeImportsWithSuggestions, + selectionRange + ).filter(suggestion => { + // Discard suggestions that have empty string insertText after right context merge and can't be displayed anymore + if (suggestion.insertText === '') { + session.setSuggestionState(suggestion.itemId, 'Discard') + return false + } - return true - }) + return true + }) - suggestionsWithRightContext.forEach(suggestion => { - const cachedSuggestion = session.suggestions.find(s => s.itemId === suggestion.itemId) - if (cachedSuggestion) cachedSuggestion.insertText = suggestion.insertText.toString() - }) + suggestionsWithRightContext.forEach(suggestion => { + const cachedSuggestion = session.suggestions.find(s => s.itemId === suggestion.itemId) + if (cachedSuggestion) cachedSuggestion.insertText = suggestion.insertText.toString() + }) - // TODO: need dedupe after right context merging but I don't see one - session.suggestionsAfterRightContextMerge.push(...suggestionsWithRightContext) + // TODO: need dedupe after right context merging but I don't see one + session.suggestionsAfterRightContextMerge.push(...suggestionsWithRightContext) - session.codewhispererSuggestionImportCount = - session.codewhispererSuggestionImportCount + - suggestionsWithRightContext.reduce((total, suggestion) => { - return total + (suggestion.mostRelevantMissingImports?.length || 0) - }, 0) + session.codewhispererSuggestionImportCount = + session.codewhispererSuggestionImportCount + + suggestionsWithRightContext.reduce((total, suggestion) => { + return total + (suggestion.mostRelevantMissingImports?.length || 0) + }, 0) - // If after all server-side filtering no suggestions can be displayed, and there is no nextToken - // close session and return empty results - if ( - session.suggestionsAfterRightContextMerge.length === 0 && - !suggestionResponse.responseContext.nextToken - ) { - sessionManager.closeSession(session) - await emitUserTriggerDecisionTelemetry( - telemetry, - telemetryService, - session, - timeSinceLastUserModification - ) - - return EMPTY_RESULT - } + // If after all server-side filtering no suggestions can be displayed, and there is no nextToken + // close session and return empty results + if ( + session.suggestionsAfterRightContextMerge.length === 0 && + !suggestionResponse.responseContext.nextToken + ) { + completionSessionManager.closeSession(session) + await emitUserTriggerDecisionTelemetry( + telemetry, + telemetryService, + session, + timeSinceLastUserModification + ) - return { - items: suggestionsWithRightContext, - sessionId: session.id, - partialResultToken: suggestionResponse.responseContext.nextToken, - } - } else { - session.hasEditsPending = suggestionResponse.responseContext.nextToken ? true : false - return { - items: suggestionResponse.suggestions - .map(suggestion => { - // Check if this suggestion is similar to a previously rejected edit - const isSimilarToRejected = rejectedEditTracker.isSimilarToRejected( - suggestion.content, - textDocument?.uri || '' - ) + return EMPTY_RESULT + } - if (isSimilarToRejected) { - // Mark as rejected in the session - session.setSuggestionState(suggestion.itemId, 'Reject') - logging.debug( - `[EDIT_PREDICTION] Filtered out suggestion similar to previously rejected edit` - ) - // Return empty item that will be filtered out - return { - insertText: '', - isInlineEdit: true, - itemId: suggestion.itemId, - } - } - - return { - insertText: suggestion.content, - isInlineEdit: true, - itemId: suggestion.itemId, - } - }) - .filter(item => item.insertText !== ''), - sessionId: session.id, - partialResultToken: suggestionResponse.responseContext.nextToken, - } + return { + items: suggestionsWithRightContext, + sessionId: session.id, + partialResultToken: suggestionResponse.responseContext.nextToken, } } @@ -656,7 +522,8 @@ export const CodewhispererServerFactory = logging.log('Recommendation failure: ' + error) emitServiceInvocationFailure(telemetry, session, error) - sessionManager.closeSession(session) + // UTDE telemetry is not needed here because in error cases we don't care about UTDE for errored out sessions + completionSessionManager.closeSession(session) let translatedError = error @@ -715,6 +582,8 @@ export const CodewhispererServerFactory = removedDiagnostics, } = params + const sessionManager = params.isInlineEdit ? editSessionManager : completionSessionManager + const session = sessionManager.getSessionById(sessionId) if (!session) { logging.log(`ERROR: Session ID ${sessionId} was not found`) @@ -722,7 +591,9 @@ export const CodewhispererServerFactory = } if (session.state !== 'ACTIVE') { - logging.log(`ERROR: Trying to record trigger decision for not-active session ${sessionId}`) + logging.log( + `ERROR: Trying to record trigger decision for not-active session ${sessionId} with wrong state ${session.state}` + ) return } @@ -731,36 +602,29 @@ export const CodewhispererServerFactory = ) const isAccepted = acceptedItemId ? true : false const acceptedSuggestion = session.suggestions.find(s => s.itemId === acceptedItemId) - let addedCharactersForEditSuggestion = '' - let deletedCharactersForEditSuggestion = '' - if (acceptedSuggestion !== undefined) { - if (acceptedSuggestion) { - codePercentageTracker.countSuccess(session.language) - if (session.suggestionType === SuggestionType.EDIT && acceptedSuggestion.content) { - // [acceptedSuggestion.insertText] will be undefined for NEP suggestion. Use [acceptedSuggestion.content] instead. - // Since [acceptedSuggestion.content] is in the form of a diff, transform the content into addedCharacters and deletedCharacters. - const addedAndDeletedChars = getAddedAndDeletedChars(acceptedSuggestion.content) - if (addedAndDeletedChars) { - addedCharactersForEditSuggestion = addedAndDeletedChars.addedCharacters - deletedCharactersForEditSuggestion = addedAndDeletedChars.deletedCharacters - - codePercentageTracker.countAcceptedTokens( - session.language, - addedCharactersForEditSuggestion - ) - codePercentageTracker.countTotalTokens( - session.language, - addedCharactersForEditSuggestion, - true - ) - enqueueCodeDiffEntry(session, acceptedSuggestion, addedCharactersForEditSuggestion) - } - } else if (acceptedSuggestion.insertText) { - codePercentageTracker.countAcceptedTokens(session.language, acceptedSuggestion.insertText) - codePercentageTracker.countTotalTokens(session.language, acceptedSuggestion.insertText, true) + let addedLengthForEdits = 0 + let deletedLengthForEdits = 0 + if (acceptedSuggestion) { + codePercentageTracker.countSuccess(session.language) + if (session.suggestionType === SuggestionType.EDIT && acceptedSuggestion.content) { + // [acceptedSuggestion.insertText] will be undefined for NEP suggestion. Use [acceptedSuggestion.content] instead. + // Since [acceptedSuggestion.content] is in the form of a diff, transform the content into addedCharacters and deletedCharacters. + const { addedLines, deletedLines } = getAddedAndDeletedLines(acceptedSuggestion.content) + const charDiffResult = getCharacterDifferences(addedLines, deletedLines) + addedLengthForEdits = charDiffResult.charactersAdded + deletedLengthForEdits = charDiffResult.charactersRemoved + + codePercentageTracker.countAcceptedTokensUsingCount( + session.language, + charDiffResult.charactersAdded + ) + codePercentageTracker.addTotalTokensForEdits(session.language, charDiffResult.charactersAdded) + enqueueCodeDiffEntry(session, acceptedSuggestion, addedLines.join('\n')) + } else if (acceptedSuggestion.insertText) { + codePercentageTracker.countAcceptedTokens(session.language, acceptedSuggestion.insertText) + codePercentageTracker.countTotalTokens(session.language, acceptedSuggestion.insertText, true) - enqueueCodeDiffEntry(session, acceptedSuggestion) - } + enqueueCodeDiffEntry(session, acceptedSuggestion) } } @@ -799,24 +663,19 @@ export const CodewhispererServerFactory = if (firstCompletionDisplayLatency) emitPerceivedLatencyTelemetry(telemetry, session) // Always emit user trigger decision at session close - // Close session unless Edit suggestion was accepted with more pending - const shouldKeepSessionOpen = - session.suggestionType === SuggestionType.EDIT && isAccepted && session.hasEditsPending - - if (!shouldKeepSessionOpen) { - sessionManager.closeSession(session) - } - const streakLength = editsEnabled ? sessionManager.getAndUpdateStreakLength(isAccepted) : 0 + sessionManager.closeSession(session) + const streakLength = editsEnabled ? streakTracker.getAndUpdateStreakLength(isAccepted) : 0 await emitUserTriggerDecisionTelemetry( telemetry, telemetryService, session, timeSinceLastUserModification, - addedCharactersForEditSuggestion.length, - deletedCharactersForEditSuggestion.length, + addedLengthForEdits, + deletedLengthForEdits, addedDiagnostics, removedDiagnostics, - streakLength + streakLength, + Object.keys(params.completionSessionResult)[0] ) } @@ -833,10 +692,9 @@ export const CodewhispererServerFactory = userWrittenCodeTracker.customizationArn = customizationArn } logging.debug(`CodePercentageTracker customizationArn updated to ${customizationArn}`) - /* - The flag enableTelemetryEventsToDestination is set to true temporarily. It's value will be determined through destination - configuration post all events migration to STE. It'll be replaced by qConfig['enableTelemetryEventsToDestination'] === true - */ + + // The flag enableTelemetryEventsToDestination is set to true temporarily. It's value will be determined through destination + // configuration post all events migration to STE. It'll be replaced by qConfig['enableTelemetryEventsToDestination'] === true // const enableTelemetryEventsToDestination = true // telemetryService.updateEnableTelemetryEventsToDestination(enableTelemetryEventsToDestination) telemetryService.updateOptOutPreference(optOutTelemetryPreference) @@ -859,7 +717,9 @@ export const CodewhispererServerFactory = ?.inlineCompletionWithReferences?.inlineEditSupport ?? false telemetryService = new TelemetryService(amazonQServiceManager, credentialsProvider, telemetry, logging) - telemetryService.updateUserContext(makeUserContextObject(clientParams, runtime.platform, 'INLINE')) + telemetryService.updateUserContext( + makeUserContextObject(clientParams, runtime.platform, 'INLINE', amazonQServiceManager.serverInfo) + ) codePercentageTracker = new CodePercentageTracker(telemetryService) codeDiffTracker = new CodeDiffTracker( @@ -892,9 +752,32 @@ export const CodewhispererServerFactory = ) await amazonQServiceManager.addDidChangeConfigurationListener(updateConfiguration) + + editCompletionHandler = new EditCompletionHandler( + logging, + clientParams, + workspace, + amazonQServiceManager, + editSessionManager, + cursorTracker, + recentEditTracker, + rejectedEditTracker, + documentChangedListener, + telemetry, + telemetryService, + credentialsProvider + ) + } + + const onEditCompletion = async ( + param: InlineCompletionWithReferencesParams, + token: CancellationToken + ): Promise => { + return await editCompletionHandler.onEditCompletion(param, token) } lsp.extensions.onInlineCompletionWithReferences(onInlineCompletionHandler) + lsp.extensions.onEditCompletion(onEditCompletion) lsp.extensions.onLogInlineCompletionSessionResults(onLogInlineCompletionSessionResultsHandler) lsp.onInitialized(onInitializedHandler) @@ -914,7 +797,7 @@ export const CodewhispererServerFactory = return } // exclude cases that the document change is from Q suggestions - const currentSession = sessionManager.getCurrentSession() + const currentSession = completionSessionManager.getCurrentSession() if ( !currentSession?.suggestions.some( suggestion => suggestion?.insertText && suggestion.insertText === change.text @@ -930,13 +813,11 @@ export const CodewhispererServerFactory = } lastUserModificationTime = new Date().getTime() + documentChangedListener.onDocumentChanged(p) + editCompletionHandler.documentChanged() + // Process document changes with RecentEditTracker. if (editsEnabled && recentEditTracker) { - logging.log( - `[SERVER] Processing document change with RecentEditTracker: ${p.textDocument.uri}, version: ${textDocument.version}` - ) - logging.log(`[SERVER] Change details: ${p.contentChanges.length} changes`) - await recentEditTracker.handleDocumentChange({ uri: p.textDocument.uri, languageId: textDocument.languageId, @@ -992,3 +873,31 @@ export const CodewhispererServerFactory = export const CodeWhispererServerIAM = CodewhispererServerFactory(getOrThrowBaseIAMServiceManager) export const CodeWhispererServerToken = CodewhispererServerFactory(getOrThrowBaseTokenServiceManager) + +export const getLanguageIdFromUri = (uri: string, logging?: any): string => { + try { + if (uri.startsWith('vscode-notebook-cell:')) { + // use python for now as lsp does not support JL cell language detection + return 'python' + } + const extension = uri.split('.').pop()?.toLowerCase() + return ABAP_EXTENSIONS.has(extension || '') ? 'abap' : '' + } catch (err) { + logging?.log(`Error parsing URI to determine language: ${uri}: ${err}`) + return '' + } +} + +const getTextDocument = async (uri: string, workspace: any, logging: any): Promise => { + let textDocument = await workspace.getTextDocument(uri) + if (!textDocument) { + try { + const content = await workspace.fs.readFile(URI.parse(uri).fsPath) + const languageId = getLanguageIdFromUri(uri) + textDocument = TextDocument.create(uri, languageId, 0, content) + } catch (err) { + logging.log(`Unable to load from ${uri}: ${err}`) + } + } + return textDocument +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/constants.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/constants.ts index f1fb6e0759..8cab372053 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/constants.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/constants.ts @@ -1,3 +1,30 @@ export const FILE_URI_CHARS_LIMIT = 1024 export const FILENAME_CHARS_LIMIT = 1024 export const CONTEXT_CHARACTERS_LIMIT = 10240 +export const EMPTY_RESULT = { sessionId: '', items: [] } +export const EDIT_DEBOUNCE_INTERVAL_MS = 500 +// ABAP ADT extensions commonly used with Eclipse +export const ABAP_EXTENSIONS = new Set([ + 'asprog', + 'aclass', + 'asinc', + 'aint', + 'assrvds', + 'asbdef', + 'asddls', + 'astablds', + 'astabldt', + 'amdp', + 'apack', + 'asrv', + 'aobj', + 'aexit', + 'abdef', + 'acinc', + 'asfugr', + 'apfugr', + 'asfunc', + 'asfinc', + 'apfunc', + 'apfinc', +]) diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/diffUtils.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/diffUtils.ts index 66cbeb3f23..0899dd5e46 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/diffUtils.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/diffUtils.ts @@ -213,6 +213,18 @@ export function applyUnifiedDiff(docText: string, unifiedDiff: string): string { } } +export function getAddedAndDeletedLines(unifiedDiff: string): { addedLines: string[]; deletedLines: string[] } { + const lines = unifiedDiff.split('\n') + const addedLines = lines.filter(line => line.startsWith('+') && !line.startsWith('+++')).map(line => line.slice(1)) + const deletedLines = lines + .filter(line => line.startsWith('-') && !line.startsWith('---')) + .map(line => line.slice(1)) + return { + addedLines, + deletedLines, + } +} + // src https://github.com/aws/aws-toolkit-vscode/blob/3921457b0a2094b831beea0d66cc2cbd2a833890/packages/amazonq/src/app/inline/EditRendering/diffUtils.ts#L147 export function getAddedAndDeletedChars(unifiedDiff: string): { addedCharacters: string @@ -254,3 +266,58 @@ export function getAddedAndDeletedChars(unifiedDiff: string): { deletedCharacters, } } + +/** + * Calculate character differences between added and deleted text blocks using LCS + */ +export interface CharDiffResult { + charactersAdded: number + charactersRemoved: number +} + +/** + * Find longest common subsequence length between two strings + */ +function lcsLength(str1: string, str2: string): number[][] { + const m = str1.length + const n = str2.length + const dp = Array(m + 1) + .fill(null) + .map(() => Array(n + 1).fill(0)) + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (str1[i - 1] === str2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1 + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]) + } + } + } + + return dp +} + +/** + * Calculate character differences between added and deleted blocks + */ +export function getCharacterDifferences(addedLines: string[], deletedLines: string[]): CharDiffResult { + const addedText = addedLines.join('\n') + const deletedText = deletedLines.join('\n') + + if (addedText.length === 0) { + return { charactersAdded: 0, charactersRemoved: deletedText.length } + } + + if (deletedText.length === 0) { + return { charactersAdded: addedText.length, charactersRemoved: 0 } + } + + const lcsTable = lcsLength(deletedText, addedText) + const lcsLen = lcsTable[deletedText.length][addedText.length] + + return { + charactersAdded: addedText.length - lcsLen, + charactersRemoved: deletedText.length - lcsLen, + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/documentChangedListener.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/documentChangedListener.ts new file mode 100644 index 0000000000..302eab1159 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/documentChangedListener.ts @@ -0,0 +1,19 @@ +import { DidChangeTextDocumentParams } from '@aws/language-server-runtimes/protocol' + +export class DocumentChangedListener { + private _lastUserModificationTime: number = 0 + private _timeSinceLastUserModification: number = 0 + get timeSinceLastUserModification(): number { + return this._timeSinceLastUserModification + } + + constructor() {} + + onDocumentChanged(e: DidChangeTextDocumentParams) { + // Record last user modification time for any document + if (this._lastUserModificationTime) { + this._timeSinceLastUserModification = new Date().getTime() - this._lastUserModificationTime + } + this._lastUserModificationTime = new Date().getTime() + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts new file mode 100644 index 0000000000..193521cce9 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts @@ -0,0 +1,430 @@ +import { + CancellationToken, + InitializeParams, + InlineCompletionListWithReferences, + InlineCompletionTriggerKind, + InlineCompletionWithReferencesParams, + LSPErrorCodes, + Range, + ResponseError, + TextDocument, +} from '@aws/language-server-runtimes/protocol' +import { RecentEditTracker } from './tracker/codeEditTracker' +import { CredentialsProvider, Logging, Telemetry, Workspace } from '@aws/language-server-runtimes/server-interface' +import { + CodeWhispererServiceToken, + GenerateSuggestionsRequest, + GenerateSuggestionsResponse, + getFileContext, +} from '../../shared/codeWhispererService' +import { CodeWhispererSession, SessionManager } from './session/sessionManager' +import { CursorTracker } from './tracker/cursorTracker' +import { CodewhispererLanguage, getSupportedLanguageId } from '../../shared/languageDetection' +import { WorkspaceFolderManager } from '../workspaceContext/workspaceFolderManager' +import { shouldTriggerEdits } from './trigger' +import { + emitEmptyUserTriggerDecisionTelemetry, + emitServiceInvocationFailure, + emitServiceInvocationTelemetry, + emitUserTriggerDecisionTelemetry, +} from './telemetry' +import { TelemetryService } from '../../shared/telemetry/telemetryService' +import { textUtils } from '@aws/lsp-core' +import { AmazonQBaseServiceManager } from '../../shared/amazonQServiceManager/BaseAmazonQServiceManager' +import { RejectedEditTracker } from './tracker/rejectedEditTracker' +import { getErrorMessage, hasConnectionExpired } from '../../shared/utils' +import { AmazonQError, AmazonQServiceConnectionExpiredError } from '../../shared/amazonQServiceManager/errors' +import { DocumentChangedListener } from './documentChangedListener' +import { EMPTY_RESULT, EDIT_DEBOUNCE_INTERVAL_MS } from './constants' +import { StreakTracker } from './tracker/streakTracker' + +export class EditCompletionHandler { + private readonly editsEnabled: boolean + private debounceTimeout: NodeJS.Timeout | undefined + private isWaiting: boolean = false + private hasDocumentChangedSinceInvocation: boolean = false + private readonly streakTracker: StreakTracker + + constructor( + readonly logging: Logging, + readonly clientMetadata: InitializeParams, + readonly workspace: Workspace, + readonly amazonQServiceManager: AmazonQBaseServiceManager, + readonly sessionManager: SessionManager, + readonly cursorTracker: CursorTracker, + readonly recentEditsTracker: RecentEditTracker, + readonly rejectedEditTracker: RejectedEditTracker, + readonly documentChangedListener: DocumentChangedListener, + readonly telemetry: Telemetry, + readonly telemetryService: TelemetryService, + readonly credentialsProvider: CredentialsProvider + ) { + this.editsEnabled = + this.clientMetadata.initializationOptions?.aws?.awsClientCapabilities?.textDocument + ?.inlineCompletionWithReferences?.inlineEditSupport ?? false + this.streakTracker = StreakTracker.getInstance() + } + + get codeWhispererService() { + return this.amazonQServiceManager.getCodewhispererService() + } + + /** + * This is a workaround to refresh the debounce timer when user is typing quickly. + * Adding debounce at function call doesnt work because server won't process second request until first request is processed. + * Also as a followup, ideally it should be a message/event publish/subscribe pattern instead of manual invocation like this + */ + documentChanged() { + if (this.debounceTimeout) { + if (this.isWaiting) { + this.hasDocumentChangedSinceInvocation = true + } else { + this.logging.info(`refresh and debounce edits suggestion for another ${EDIT_DEBOUNCE_INTERVAL_MS}`) + this.debounceTimeout.refresh() + } + } + } + + async onEditCompletion( + params: InlineCompletionWithReferencesParams, + token: CancellationToken + ): Promise { + // On every new completion request close current inflight session. + const currentSession = this.sessionManager.getCurrentSession() + if (currentSession && currentSession.state == 'REQUESTING' && !params.partialResultToken) { + // this REQUESTING state only happens when the session is initialized, which is rare + currentSession.discardInflightSessionOnNewInvocation = true + } + + if (this.cursorTracker) { + this.cursorTracker.trackPosition(params.textDocument.uri, params.position) + } + const textDocument = await this.workspace.getTextDocument(params.textDocument.uri) + if (!textDocument) { + this.logging.warn(`textDocument [${params.textDocument.uri}] not found`) + return EMPTY_RESULT + } + + if (!(this.codeWhispererService instanceof CodeWhispererServiceToken)) { + return EMPTY_RESULT + } + + // request for new session + const inferredLanguageId = getSupportedLanguageId(textDocument) + if (!inferredLanguageId) { + this.logging.log( + `textDocument [${params.textDocument.uri}] with languageId [${textDocument.languageId}] not supported` + ) + return EMPTY_RESULT + } + + if (params.partialResultToken && currentSession) { + // Close ACTIVE session. We shouldn't record Discard trigger decision for trigger with nextToken. + if (currentSession && currentSession.state === 'ACTIVE') { + this.sessionManager.discardSession(currentSession) + } + + const newSession = this.sessionManager.createSession({ + document: textDocument, + startPosition: params.position, + triggerType: 'AutoTrigger', + language: currentSession.language, + requestContext: currentSession.requestContext, + autoTriggerType: undefined, + triggerCharacter: '', + classifierResult: undefined, + classifierThreshold: undefined, + credentialStartUrl: currentSession.credentialStartUrl, + supplementalMetadata: currentSession.supplementalMetadata, + customizationArn: currentSession.customizationArn, + }) + // subsequent paginated requests for current session + try { + const suggestionResponse = await this.codeWhispererService.generateSuggestions({ + ...newSession.requestContext, + nextToken: `${params.partialResultToken}`, + }) + return await this.processSuggestionResponse( + suggestionResponse, + newSession, + true, + params.context.selectedCompletionInfo?.range + ) + } catch (error) { + return this.handleSuggestionsErrors(error as Error, currentSession) + } + } + + return new Promise(async resolve => { + this.debounceTimeout = setTimeout(async () => { + try { + this.isWaiting = true + const result = await this._invoke( + params, + token, + textDocument, + inferredLanguageId, + currentSession + ).finally(() => { + this.isWaiting = false + }) + if (this.hasDocumentChangedSinceInvocation) { + this.logging.info( + 'EditCompletionHandler - Document changed during execution, resolving empty result' + ) + resolve({ + sessionId: SessionManager.getInstance('EDITS').getActiveSession()?.id ?? '', + items: [], + }) + } else { + this.logging.info('EditCompletionHandler - No document changes, resolving result') + resolve(result) + } + } finally { + this.debounceTimeout = undefined + this.hasDocumentChangedSinceInvocation = false + } + }, EDIT_DEBOUNCE_INTERVAL_MS) + }) + } + + async _invoke( + params: InlineCompletionWithReferencesParams, + token: CancellationToken, + textDocument: TextDocument, + inferredLanguageId: CodewhispererLanguage, + currentSession: CodeWhispererSession | undefined + ): Promise { + // Build request context + const isAutomaticLspTriggerKind = params.context.triggerKind == InlineCompletionTriggerKind.Automatic + const maxResults = isAutomaticLspTriggerKind ? 1 : 5 + const fileContext = getFileContext({ + textDocument, + inferredLanguageId, + position: params.position, + workspaceFolder: this.workspace.getWorkspaceFolder(textDocument.uri), + }) + + const workspaceState = WorkspaceFolderManager.getInstance()?.getWorkspaceState() + const workspaceId = workspaceState?.webSocketClient?.isConnected() ? workspaceState.workspaceId : undefined + + const qEditsTrigger = shouldTriggerEdits( + this.codeWhispererService, + fileContext, + params, + this.cursorTracker, + this.recentEditsTracker, + this.sessionManager, + true + ) + + if (!qEditsTrigger) { + return EMPTY_RESULT + } + + const generateCompletionReq: GenerateSuggestionsRequest = { + fileContext: fileContext, + maxResults: maxResults, + predictionTypes: ['EDITS'], + workspaceId: workspaceId, + } + + if (qEditsTrigger) { + generateCompletionReq.editorState = { + document: { + relativeFilePath: textDocument.uri, + programmingLanguage: { + languageName: generateCompletionReq.fileContext.programmingLanguage.languageName, + }, + text: textDocument.getText(), + }, + cursorState: { + position: { + line: params.position.line, + character: params.position.character, + }, + }, + } + } + + const supplementalContext = await this.codeWhispererService.constructSupplementalContext( + textDocument, + params.position, + this.workspace, + this.recentEditsTracker, + this.logging, + token, + params.openTabFilepaths, + { + includeRecentEdits: true, + } + ) + if (supplementalContext) { + generateCompletionReq.supplementalContexts = supplementalContext.items + } + + // Close ACTIVE session and record Discard trigger decision immediately + if (currentSession && currentSession.state === 'ACTIVE') { + // Emit user trigger decision at session close time for active session + this.sessionManager.discardSession(currentSession) + const streakLength = this.editsEnabled ? this.streakTracker.getAndUpdateStreakLength(false) : 0 + await emitUserTriggerDecisionTelemetry( + this.telemetry, + this.telemetryService, + currentSession, + this.documentChangedListener.timeSinceLastUserModification, + 0, + 0, + [], + [], + streakLength + ) + } + + const newSession = this.sessionManager.createSession({ + document: textDocument, + startPosition: params.position, + triggerType: isAutomaticLspTriggerKind ? 'AutoTrigger' : 'OnDemand', + language: fileContext.programmingLanguage.languageName, + requestContext: generateCompletionReq, + autoTriggerType: undefined, + triggerCharacter: '', + classifierResult: undefined, + classifierThreshold: undefined, + credentialStartUrl: this.credentialsProvider.getConnectionMetadata?.()?.sso?.startUrl ?? undefined, + supplementalMetadata: supplementalContext?.supContextData, + customizationArn: textUtils.undefinedIfEmpty(this.codeWhispererService.customizationArn), + }) + + try { + const suggestionResponse = await this.codeWhispererService.generateSuggestions(generateCompletionReq) + return await this.processSuggestionResponse( + suggestionResponse, + newSession, + true, + params.context.selectedCompletionInfo?.range + ) + } catch (error) { + return this.handleSuggestionsErrors(error as Error, newSession) + } + } + + async processSuggestionResponse( + suggestionResponse: GenerateSuggestionsResponse, + session: CodeWhispererSession, + isNewSession: boolean, + selectionRange?: Range, + textDocument?: TextDocument + ) { + // TODO: we haven't decided how to do these telemetry for Edits suggestions + // codePercentageTracker.countInvocation(session.language) + // userWrittenCodeTracker?.recordUsageCount(session.language) + + if (isNewSession) { + // Populate the session with information from codewhisperer response + session.suggestions = suggestionResponse.suggestions + session.responseContext = suggestionResponse.responseContext + session.codewhispererSessionId = suggestionResponse.responseContext.codewhispererSessionId + session.timeToFirstRecommendation = new Date().getTime() - session.startTime + session.suggestionType = suggestionResponse.suggestionType + } else { + session.suggestions = [...session.suggestions, ...suggestionResponse.suggestions] + } + + // Emit service invocation telemetry for every request sent to backend + emitServiceInvocationTelemetry(this.telemetry, session, suggestionResponse.responseContext.requestId) + + // Discard previous inflight API response due to new trigger + if (session.discardInflightSessionOnNewInvocation) { + session.discardInflightSessionOnNewInvocation = false + this.sessionManager.discardSession(session) + const streakLength = this.editsEnabled ? this.streakTracker.getAndUpdateStreakLength(false) : 0 + await emitUserTriggerDecisionTelemetry( + this.telemetry, + this.telemetryService, + session, + this.documentChangedListener.timeSinceLastUserModification, + 0, + 0, + [], + [], + streakLength + ) + } + + // API response was recieved, we can activate session now + this.sessionManager.activateSession(session) + + // Process suggestions to apply Empty or Filter filters + if (suggestionResponse.suggestions.length === 0) { + this.sessionManager.closeSession(session) + await emitEmptyUserTriggerDecisionTelemetry( + this.telemetryService, + session, + this.documentChangedListener.timeSinceLastUserModification, + this.editsEnabled ? this.streakTracker.getAndUpdateStreakLength(false) : 0 + ) + return EMPTY_RESULT + } + + return { + items: suggestionResponse.suggestions + .map(suggestion => { + // Check if this suggestion is similar to a previously rejected edit + const isSimilarToRejected = this.rejectedEditTracker.isSimilarToRejected( + suggestion.content, + textDocument?.uri || '' + ) + + if (isSimilarToRejected) { + // Mark as rejected in the session + session.setSuggestionState(suggestion.itemId, 'Reject') + this.logging.debug( + `[EDIT_PREDICTION] Filtered out suggestion similar to previously rejected edit` + ) + // Return empty item that will be filtered out + return { + insertText: '', + isInlineEdit: true, + itemId: suggestion.itemId, + } + } + + return { + insertText: suggestion.content, + isInlineEdit: true, + itemId: suggestion.itemId, + } + }) + .filter(item => item.insertText !== ''), + sessionId: session.id, + partialResultToken: suggestionResponse.responseContext.nextToken, + } + } + + handleSuggestionsErrors(error: Error, session: CodeWhispererSession): InlineCompletionListWithReferences { + this.logging.log('Recommendation failure: ' + error) + emitServiceInvocationFailure(this.telemetry, session, error) + + // UTDE telemetry is not needed here because in error cases we don't care about UTDE for errored out sessions + this.sessionManager.closeSession(session) + + let translatedError = error + + if (hasConnectionExpired(error)) { + translatedError = new AmazonQServiceConnectionExpiredError(getErrorMessage(error)) + } + + if (translatedError instanceof AmazonQError) { + throw new ResponseError( + LSPErrorCodes.RequestFailed, + translatedError.message || 'Error processing suggestion requests', + { + awsErrorCode: translatedError.code, + } + ) + } + + return EMPTY_RESULT + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.test.ts index db34f885ff..ddf06ffd40 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.test.ts @@ -529,12 +529,12 @@ describe('SessionManager', function () { assert.strictEqual(manager.getCurrentSession()?.state, 'REQUESTING') }) - it('should deactivate previous session when creating a new session', function () { + it('should not deactivate previous session when creating a new session', function () { const manager = SessionManager.getInstance() const session = manager.createSession(data) session.activate() manager.createSession(data) - assert.strictEqual(session.state, 'CLOSED') + assert.strictEqual(session.state, 'ACTIVE') }) it('should set previous active session trigger decision from discarded REQUESTING session', function () { @@ -548,7 +548,7 @@ describe('SessionManager', function () { assert.strictEqual(session2.previousTriggerDecision, 'Discard') }) - it('should set previous active session trigger decision to new session object', function () { + it('should not set previous active session trigger decision to new session object if it is not closed', function () { const manager = SessionManager.getInstance() const session1 = manager.createSession(data) assert.strictEqual(session1?.state, 'REQUESTING') @@ -557,22 +557,8 @@ describe('SessionManager', function () { const session2 = manager.createSession(data) - assert.strictEqual(session1?.state, 'CLOSED') - assert.strictEqual(session2.previousTriggerDecision, 'Empty') - }) - }) - - describe('closeCurrentSession()', function () { - it('should add the current session to the sessions log if it is active', function () { - const manager = SessionManager.getInstance() - const session = manager.createSession(data) - assert.strictEqual(session.state, 'REQUESTING') - session.activate() - assert.strictEqual(session.state, 'ACTIVE') - manager.closeCurrentSession() - assert.strictEqual(manager.getSessionsLog().length, 1) - assert.strictEqual(manager.getSessionsLog()[0], session) - assert.strictEqual(session.state, 'CLOSED') + assert.strictEqual(session1?.state, 'ACTIVE') + assert.strictEqual(session2.previousTriggerDecision, undefined) }) }) @@ -599,7 +585,6 @@ describe('SessionManager', function () { session2.activate() const session3 = manager.createSession(data) session3.activate() - manager.closeCurrentSession() const result = manager.getPreviousSession() assert.strictEqual(result, session3) assert.strictEqual(manager.getSessionsLog().length, 3) @@ -612,7 +597,6 @@ describe('SessionManager', function () { const session2 = manager.createSession(data) const session3 = manager.createSession(data) session3.activate() - manager.closeCurrentSession() const result = manager.getPreviousSession() assert.strictEqual(result, session3) assert.strictEqual(manager.getSessionsLog().length, 3) @@ -632,7 +616,6 @@ describe('SessionManager', function () { session.activate() const session2 = manager.createSession({ ...data, triggerType: 'AutoTrigger' }) session2.activate() - manager.closeCurrentSession() assert.strictEqual(manager.getSessionsLog().length, 2) const sessionId = session.id @@ -644,7 +627,6 @@ describe('SessionManager', function () { const manager = SessionManager.getInstance() const session = manager.createSession(data) session.activate() - manager.closeCurrentSession() assert.strictEqual(manager.getSessionsLog().length, 1) const sessionId = session.id + '1' @@ -691,46 +673,4 @@ describe('SessionManager', function () { assert.equal(session.getSuggestionState('id4'), 'Discard') }) }) - - describe('getAndUpdateStreakLength()', function () { - it('should return 0 if user rejects suggestion A', function () { - const manager = SessionManager.getInstance() - - assert.equal(manager.getAndUpdateStreakLength(false), -1) - assert.equal(manager.streakLength, 0) - }) - - it('should return -1 for A and 1 for B if user accepts suggestion A and rejects B', function () { - const manager = SessionManager.getInstance() - - assert.equal(manager.getAndUpdateStreakLength(true), -1) - assert.equal(manager.streakLength, 1) - assert.equal(manager.getAndUpdateStreakLength(false), 1) - assert.equal(manager.streakLength, 0) - }) - - it('should return -1 for A, -1 for B, and 2 for C if user accepts A, accepts B, and rejects C', function () { - const manager = SessionManager.getInstance() - - assert.equal(manager.getAndUpdateStreakLength(true), -1) - assert.equal(manager.streakLength, 1) - assert.equal(manager.getAndUpdateStreakLength(true), -1) - assert.equal(manager.streakLength, 2) - assert.equal(manager.getAndUpdateStreakLength(false), 2) - assert.equal(manager.streakLength, 0) - }) - - it('should return -1 for A, -1 for B, and 1 for C if user accepts A, make an edit, accepts B, and rejects C', function () { - const manager = SessionManager.getInstance() - - assert.equal(manager.getAndUpdateStreakLength(true), -1) - assert.equal(manager.streakLength, 1) - assert.equal(manager.getAndUpdateStreakLength(false), 1) - assert.equal(manager.streakLength, 0) - assert.equal(manager.getAndUpdateStreakLength(true), -1) - assert.equal(manager.streakLength, 1) - assert.equal(manager.getAndUpdateStreakLength(false), 1) - assert.equal(manager.streakLength, 0) - }) - }) }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts index 26a9b3344a..34dbb12538 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts @@ -14,11 +14,10 @@ import { } from '../../../shared/codeWhispererService' import { CodewhispererLanguage } from '../../../shared/languageDetection' import { CodeWhispererSupplementalContext } from '../../../shared/models/model' -import { Logging } from '@aws/language-server-runtimes/server-interface' type SessionState = 'REQUESTING' | 'ACTIVE' | 'CLOSED' | 'ERROR' | 'DISCARD' export type UserDecision = 'Empty' | 'Filter' | 'Discard' | 'Accept' | 'Ignore' | 'Reject' | 'Unseen' -type UserTriggerDecision = 'Accept' | 'Reject' | 'Empty' | 'Discard' +export type UserTriggerDecision = 'Accept' | 'Reject' | 'Empty' | 'Discard' interface CachedSuggestion extends Suggestion { insertText?: string @@ -45,7 +44,13 @@ export class CodeWhispererSession { startTime: number // Time when Session was closed and final state of user decisions is recorded in suggestionsStates closeTime?: number = 0 - state: SessionState + private _state: SessionState + get state(): SessionState { + return this._state + } + private set state(newState: SessionState) { + this._state = newState + } codewhispererSessionId?: string startPosition: Position = { line: 0, @@ -80,9 +85,7 @@ export class CodeWhispererSession { includeImportsWithSuggestions?: boolean codewhispererSuggestionImportCount: number = 0 suggestionType?: string - hasEditsPending?: boolean = false // Track the most recent itemId for paginated Edit suggestions - recentItemId?: string constructor(data: SessionData) { this.id = this.generateSessionId() @@ -98,7 +101,8 @@ export class CodeWhispererSession { this.classifierThreshold = data.classifierThreshold this.customizationArn = data.customizationArn this.supplementalMetadata = data.supplementalMetadata - this.state = 'REQUESTING' + this._state = 'REQUESTING' + this.startTime = new Date().getTime() } @@ -207,8 +211,6 @@ export class CodeWhispererSession { // No recommendation was accepted, but user have seen this suggestion this.setSuggestionState(itemId, 'Reject') } - - this.recentItemId = itemId } this.firstCompletionDisplayLatency = firstCompletionDisplayLatency @@ -259,15 +261,15 @@ export class CodeWhispererSession { * Determines trigger decision based on the most recent user action. * Uses the last processed itemId to determine the overall session decision. */ - getLatestUserTriggerDecision(): UserTriggerDecision | undefined { + getUserTriggerDecision(itemId?: string): UserTriggerDecision | undefined { // Force Discard trigger decision when session was explicitly discarded by server if (this.state === 'DISCARD') { return 'Discard' } - if (!this.recentItemId) return + if (!itemId) return - const state = this.getSuggestionState(this.recentItemId) + const state = this.getSuggestionState(itemId) if (state === 'Accept') return 'Accept' if (state === 'Reject') return 'Reject' return state === 'Empty' ? 'Empty' : 'Discard' @@ -275,11 +277,11 @@ export class CodeWhispererSession { } export class SessionManager { - private static _instance?: SessionManager + private static _completionInstance?: SessionManager + private static _editInstance?: SessionManager private currentSession?: CodeWhispererSession private sessionsLog: CodeWhispererSession[] = [] private maxHistorySize = 5 - streakLength: number = 0 // TODO, for user decision telemetry: accepted suggestions (not necessarily the full corresponding session) should be stored for 5 minutes private constructor() {} @@ -287,22 +289,21 @@ export class SessionManager { /** * Singleton SessionManager class */ - public static getInstance(): SessionManager { - if (!SessionManager._instance) { - SessionManager._instance = new SessionManager() + public static getInstance(type: 'COMPLETIONS' | 'EDITS' = 'COMPLETIONS'): SessionManager { + if (type === 'EDITS') { + return (SessionManager._editInstance ??= new SessionManager()) } - return SessionManager._instance + return (SessionManager._completionInstance ??= new SessionManager()) } // For unit tests public static reset() { - SessionManager._instance = undefined + SessionManager._completionInstance = undefined + SessionManager._editInstance = undefined } public createSession(data: SessionData): CodeWhispererSession { - this.closeCurrentSession() - // Remove oldest session from log if (this.sessionsLog.length > this.maxHistorySize) { this.sessionsLog.shift() @@ -323,12 +324,6 @@ export class SessionManager { return session } - closeCurrentSession() { - if (this.currentSession) { - this.closeSession(this.currentSession) - } - } - closeSession(session: CodeWhispererSession) { session.close() } @@ -366,16 +361,4 @@ export class SessionManager { this.currentSession.activate() } } - - getAndUpdateStreakLength(isAccepted: boolean | undefined): number { - if (!isAccepted && this.streakLength != 0) { - const currentStreakLength = this.streakLength - this.streakLength = 0 - return currentStreakLength - } else if (isAccepted) { - // increment streakLength everytime a suggestion is accepted. - this.streakLength = this.streakLength + 1 - } - return -1 - } } diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/telemetry.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/telemetry.ts index 2abb35f801..73e3a526a4 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/telemetry.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/telemetry.ts @@ -1,6 +1,6 @@ import { Telemetry, IdeDiagnostic } from '@aws/language-server-runtimes/server-interface' import { AWSError } from 'aws-sdk' -import { CodeWhispererSession } from './session/sessionManager' +import { CodeWhispererSession, UserTriggerDecision } from './session/sessionManager' import { CodeWhispererPerceivedLatencyEvent, CodeWhispererServiceInvocationEvent } from '../../shared/telemetry/types' import { getCompletionType, isAwsError } from '../../shared/utils' import { TelemetryService } from '../../shared/telemetry/telemetryService' @@ -106,6 +106,36 @@ export const emitPerceivedLatencyTelemetry = (telemetry: Telemetry, session: Cod }) } +export async function emitEmptyUserTriggerDecisionTelemetry( + telemetryService: TelemetryService, + session: CodeWhispererSession, + timeSinceLastUserModification?: number, + streakLength?: number +) { + // Prevent reporting user decision if it was already sent + if (session.reportedUserDecision) { + return + } + + // Non-blocking + emitAggregatedUserTriggerDecisionTelemetry( + telemetryService, + session, + 'Empty', + timeSinceLastUserModification, + 0, + 0, + [], + [], + streakLength + ) + .then() + .catch(e => {}) + .finally(() => { + session.reportedUserDecision = true + }) +} + export const emitUserTriggerDecisionTelemetry = async ( telemetry: Telemetry, telemetryService: TelemetryService, @@ -115,7 +145,8 @@ export const emitUserTriggerDecisionTelemetry = async ( deletedCharsCountForEditSuggestion?: number, addedIdeDiagnostics?: IdeDiagnostic[], removedIdeDiagnostics?: IdeDiagnostic[], - streakLength?: number + streakLength?: number, + itemId?: string ) => { // Prevent reporting user decision if it was already sent if (session.reportedUserDecision) { @@ -126,7 +157,7 @@ export const emitUserTriggerDecisionTelemetry = async ( // Completions show multiple suggestions together, so aggregate all states const userTriggerDecision = session.suggestionType === SuggestionType.EDIT - ? session.getLatestUserTriggerDecision() + ? session.getUserTriggerDecision(itemId) : session.getAggregatedUserTriggerDecision() // Can not emit previous trigger decision if it's not available on the session @@ -137,6 +168,7 @@ export const emitUserTriggerDecisionTelemetry = async ( await emitAggregatedUserTriggerDecisionTelemetry( telemetryService, session, + userTriggerDecision, timeSinceLastUserModification, addedCharsCountForEditSuggestion, deletedCharsCountForEditSuggestion, @@ -145,18 +177,13 @@ export const emitUserTriggerDecisionTelemetry = async ( streakLength ) - // Mark telemetry as complete unless Edit suggestion was accepted with more pending - const hasPendingEditTelemetry = - session.suggestionType === SuggestionType.EDIT && session.acceptedSuggestionId && session.hasEditsPending - - if (!hasPendingEditTelemetry) { - session.reportedUserDecision = true - } + session.reportedUserDecision = true } export const emitAggregatedUserTriggerDecisionTelemetry = ( telemetryService: TelemetryService, session: CodeWhispererSession, + userTriggerDecision: UserTriggerDecision, timeSinceLastUserModification?: number, addedCharsCountForEditSuggestion?: number, deletedCharsCountForEditSuggestion?: number, @@ -166,6 +193,7 @@ export const emitAggregatedUserTriggerDecisionTelemetry = ( ) => { return telemetryService.emitUserTriggerDecision( session, + userTriggerDecision, timeSinceLastUserModification, addedCharsCountForEditSuggestion, deletedCharsCountForEditSuggestion, diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.test.ts new file mode 100644 index 0000000000..4c69879115 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.test.ts @@ -0,0 +1,85 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'assert' +import { StreakTracker } from './streakTracker' + +describe('StreakTracker', function () { + let tracker: StreakTracker + + beforeEach(function () { + StreakTracker.reset() + tracker = StreakTracker.getInstance() + }) + + afterEach(function () { + StreakTracker.reset() + }) + + describe('getInstance', function () { + it('should return the same instance (singleton)', function () { + const instance1 = StreakTracker.getInstance() + const instance2 = StreakTracker.getInstance() + assert.strictEqual(instance1, instance2) + }) + + it('should create new instance after reset', function () { + const instance1 = StreakTracker.getInstance() + StreakTracker.reset() + const instance2 = StreakTracker.getInstance() + assert.notStrictEqual(instance1, instance2) + }) + }) + + describe('getAndUpdateStreakLength', function () { + it('should return -1 for undefined input', function () { + const result = tracker.getAndUpdateStreakLength(undefined) + assert.strictEqual(result, -1) + }) + + it('should return -1 and increment streak on acceptance', function () { + const result = tracker.getAndUpdateStreakLength(true) + assert.strictEqual(result, -1) + }) + + it('should return -1 for rejection with zero streak', function () { + const result = tracker.getAndUpdateStreakLength(false) + assert.strictEqual(result, -1) + }) + + it('should return previous streak on rejection after acceptances', function () { + tracker.getAndUpdateStreakLength(true) + tracker.getAndUpdateStreakLength(true) + tracker.getAndUpdateStreakLength(true) + + const result = tracker.getAndUpdateStreakLength(false) + assert.strictEqual(result, 3) + }) + + it('should handle acceptance after rejection', function () { + tracker.getAndUpdateStreakLength(true) + tracker.getAndUpdateStreakLength(true) + + const resetResult = tracker.getAndUpdateStreakLength(false) + assert.strictEqual(resetResult, 2) + + tracker.getAndUpdateStreakLength(true) + const newResult = tracker.getAndUpdateStreakLength(true) + assert.strictEqual(newResult, -1) + }) + }) + + describe('cross-instance consistency', function () { + it('should maintain state across getInstance calls', function () { + const tracker1 = StreakTracker.getInstance() + tracker1.getAndUpdateStreakLength(true) + tracker1.getAndUpdateStreakLength(true) + + const tracker2 = StreakTracker.getInstance() + const result = tracker2.getAndUpdateStreakLength(false) + assert.strictEqual(result, 2) + }) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.ts new file mode 100644 index 0000000000..21d56c4d74 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.ts @@ -0,0 +1,42 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Tracks acceptance streak across both completion and edit suggestion types. + * Shared singleton to maintain consistent streak count between different code paths. + */ +export class StreakTracker { + private static _instance?: StreakTracker + private streakLength: number = 0 + + private constructor() {} + + public static getInstance(): StreakTracker { + if (!StreakTracker._instance) { + StreakTracker._instance = new StreakTracker() + } + return StreakTracker._instance + } + + public static reset() { + StreakTracker._instance = undefined + } + + /** + * Updates and returns the current streak length based on acceptance status. + * @param isAccepted Whether the suggestion was accepted + * @returns Current streak length before update, or -1 if no change + */ + public getAndUpdateStreakLength(isAccepted: boolean | undefined): number { + if (!isAccepted && this.streakLength !== 0) { + const currentStreakLength = this.streakLength + this.streakLength = 0 + return currentStreakLength + } else if (isAccepted) { + this.streakLength += 1 + } + return -1 + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/trigger.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/trigger.ts new file mode 100644 index 0000000000..305b9b6e5c --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/trigger.ts @@ -0,0 +1,57 @@ +import { SessionManager } from './session/sessionManager' +import { InlineCompletionWithReferencesParams } from '@aws/language-server-runtimes/protocol' +import { editPredictionAutoTrigger } from './auto-trigger/editPredictionAutoTrigger' +import { CursorTracker } from './tracker/cursorTracker' +import { RecentEditTracker } from './tracker/codeEditTracker' +import { + CodeWhispererServiceBase, + CodeWhispererServiceToken, + ClientFileContext, +} from '../../shared/codeWhispererService' + +export class NepTrigger {} + +export function shouldTriggerEdits( + service: CodeWhispererServiceBase, + fileContext: ClientFileContext, + inlineParams: InlineCompletionWithReferencesParams, + cursorTracker: CursorTracker, + recentEditsTracker: RecentEditTracker, + sessionManager: SessionManager, + editsEnabled: boolean +): NepTrigger | undefined { + if (!editsEnabled) { + return undefined + } + // edits type suggestion is only implemented in bearer token based IDE for now, we dont want to expose such suggestions to other platforms + if (!(service instanceof CodeWhispererServiceToken)) { + return undefined + } + + const documentChangeParams = inlineParams.documentChangeParams + const hasDocChangedParams = + documentChangeParams?.contentChanges && + documentChangeParams.contentChanges.length > 0 && + documentChangeParams.contentChanges[0].text !== undefined + + // if the client does not emit document change for the trigger, use left most character. + const triggerCharacters = hasDocChangedParams + ? documentChangeParams.contentChanges[0].text + : (fileContext.leftFileContent.trim().at(-1) ?? '') + + const previousDecision = sessionManager.getPreviousSession()?.getAggregatedUserTriggerDecision() + const res = editPredictionAutoTrigger({ + fileContext: fileContext, + lineNum: inlineParams.position.line, + char: triggerCharacters, + previousDecision: previousDecision ?? '', + cursorHistory: cursorTracker, + recentEdits: recentEditsTracker, + }) + + if (res.shouldTrigger) { + return new NepTrigger() + } else { + return undefined + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts index ad31fd15af..c583815b6b 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts @@ -308,7 +308,7 @@ describe('Telemetry', () => { sinon.assert.calledOnceWithExactly(sessionManagerSpy.closeSession, currentSession) const expectedUserTriggerDecisionMetric = aUserTriggerDecision() - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Empty') }) it('should send Empty User Decision when Codewhisperer returned empty list of suggestions', async () => { @@ -326,7 +326,7 @@ describe('Telemetry', () => { suggestions: [], suggestionsStates: new Map([]), }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Empty') }) it('should send Discard User Decision when all suggestions are filtered out by includeSuggestionsWithCodeReferences setting filter', async () => { @@ -383,7 +383,7 @@ describe('Telemetry', () => { ['cwspr-item-id-3', 'Filter'], ]), }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Discard') }) it('should send Discard User Decision when all suggestions are discarded after right context merge', async () => { @@ -467,7 +467,7 @@ describe('Telemetry', () => { maxResults: 5, }, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Discard') }) }) @@ -505,7 +505,7 @@ describe('Telemetry', () => { sinon.assert.called(telemetryServiceSpy) }) - it('should not emit User Decision event when session results are received after session was closed', async () => { + it('should not emit User Decision event after second trigger is received', async () => { setServiceResponse(DEFAULT_SUGGESTIONS, { ...EXPECTED_RESPONSE_CONTEXT, codewhispererSessionId: 'cwspr-session-id-1', @@ -519,7 +519,7 @@ describe('Telemetry', () => { sinon.assert.notCalled(sessionManagerSpy.closeSession) sinon.assert.notCalled(telemetryServiceSpy) - // Send second completion request to close first one + // Send second completion request should not close first one setServiceResponse(DEFAULT_SUGGESTIONS, { ...EXPECTED_RESPONSE_CONTEXT, codewhispererSessionId: 'cwspr-session-id-2', @@ -528,7 +528,7 @@ describe('Telemetry', () => { assert.equal(firstSession.state, 'DISCARD') assert.notEqual(firstSession, sessionManager.getCurrentSession()) - sinon.assert.calledWithExactly(sessionManagerSpy.closeSession, firstSession) + sinon.assert.notCalled(sessionManagerSpy.closeSession) // Test that session reports it's status when second request is received const expectedEvent = aUserTriggerDecision({ state: 'DISCARD', @@ -571,7 +571,7 @@ describe('Telemetry', () => { codewhispererSessionId: 'cwspr-session-id-1', }, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedEvent, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedEvent, 'Discard') telemetryServiceSpy.resetHistory() @@ -662,7 +662,7 @@ describe('Telemetry', () => { ]), acceptedSuggestionId: 'cwspr-item-id-2', }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Accept') }) it('should emit Reject User Decision event for current active completion session when session results are received without accepted suggestion', async () => { @@ -734,7 +734,7 @@ describe('Telemetry', () => { 'cwspr-item-id-3': { seen: false, accepted: false, discarded: true }, }, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Reject') }) it('should send Discard User Decision when all suggestions have Discard state', async () => { @@ -806,7 +806,7 @@ describe('Telemetry', () => { 'cwspr-item-id-3': { seen: false, accepted: false, discarded: true }, }, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Discard') }) it('should set codewhispererTimeSinceLastDocumentChange as difference between 2 any document changes', async () => { @@ -873,7 +873,7 @@ describe('Telemetry', () => { ]), closeTime: clock.now, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 5678) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Reject', 5678) }) }) @@ -932,7 +932,7 @@ describe('Telemetry', () => { codewhispererSessionId: 'cwspr-session-id-1', }, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Discard') sinon.assert.neverCalledWithMatch( telemetryServiceSpy, { @@ -996,7 +996,7 @@ describe('Telemetry', () => { codewhispererSessionId: 'cwspr-session-id-1', }, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Discard') sinon.assert.neverCalledWithMatch( telemetryServiceSpy, { @@ -1451,7 +1451,7 @@ describe('Telemetry', () => { codewhispererSessionId: 'cwspr-session-id-2', }, }), - 0 + 'Discard' ) }) }) @@ -1515,6 +1515,12 @@ describe('Telemetry', () => { 'cwspr-item-id-3': { seen: true, accepted: false, discarded: false }, }, }), + 'Reject', + 0, + 0, + 0, + undefined, + undefined, 0 ) assert.equal(firstSession?.state, 'CLOSED') diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/resources/SupportedProjects.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/resources/SupportedProjects.ts index a3d6322a07..69881dfc89 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/resources/SupportedProjects.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/resources/SupportedProjects.ts @@ -1,3 +1,6 @@ +/** + * Reference only - validation moved to backend service. + */ export const supportedProjects = [ 'AspNetCoreMvc', 'AspNetCoreWebApi', diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/validation.test.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/validation.test.ts index a8cfcf3475..90ce3d1f7a 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/validation.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/validation.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai' import { StartTransformRequest, TransformProjectMetadata } from '../models' -import { isProject, isSolution, validateProject, validateSolution } from '../validation' +import { isProject, isSolution } from '../validation' import { supportedProjects, unsupportedViewComponents } from '../resources/SupportedProjects' import mock = require('mock-fs') import { Logging } from '@aws/language-server-runtimes/server-interface' @@ -46,122 +46,4 @@ describe('Test validation functionality', () => { mockStartTransformationRequest.SelectedProjectPath = 'test.csproj' expect(isSolution(mockStartTransformationRequest)).to.equal(false) }) - - it('should return true when project is a supported type', () => { - let mockStartTransformationRequest: StartTransformRequest = sampleStartTransformRequest - const mockProjectMeta = { - Name: '', - ProjectTargetFramework: '', - ProjectPath: 'test.csproj', - SourceCodeFilePaths: [], - ProjectLanguage: '', - ProjectType: 'AspNetCoreMvc', - ExternalReferences: [], - } - mockStartTransformationRequest.ProjectMetadata.push(mockProjectMeta) - - expect(validateProject(mockStartTransformationRequest, mockedLogging)).to.equal(true) - }) - - it('should return false when project is not a supported type', () => { - let mockStartTransformationRequest: StartTransformRequest = sampleStartTransformRequest - const mockProjectMeta = { - Name: '', - ProjectTargetFramework: '', - ProjectPath: 'test.csproj', - SourceCodeFilePaths: [], - ProjectLanguage: '', - ProjectType: 'not supported', - ExternalReferences: [], - } - mockStartTransformationRequest.ProjectMetadata = [] - mockStartTransformationRequest.ProjectMetadata.push(mockProjectMeta) - - expect(validateProject(mockStartTransformationRequest, mockedLogging)).to.equal(false) - }) - - it('should return false when there is no project path that is the same as the selected project path', () => { - let mockStartTransformationRequest: StartTransformRequest = sampleStartTransformRequest - const mockProjectMeta = { - Name: '', - ProjectTargetFramework: '', - ProjectPath: 'different.csproj', - SourceCodeFilePaths: [], - ProjectLanguage: '', - ProjectType: 'AspNetCoreMvc', - ExternalReferences: [], - } - mockStartTransformationRequest.ProjectMetadata = [] - mockStartTransformationRequest.ProjectMetadata.push(mockProjectMeta) - - expect(validateProject(mockStartTransformationRequest, mockedLogging)).to.equal(false) - }) - - // New tests for AspNetWebForms validation - it('should return true when project is AspNetWebForms type', () => { - let mockStartTransformationRequest: StartTransformRequest = sampleStartTransformRequest - const mockProjectMeta = { - Name: '', - ProjectTargetFramework: '', - ProjectPath: 'test.csproj', - SourceCodeFilePaths: [], - ProjectLanguage: '', - ProjectType: 'AspNetWebForms', - ExternalReferences: [], - } - mockStartTransformationRequest.ProjectMetadata = [] - mockStartTransformationRequest.ProjectMetadata.push(mockProjectMeta) - - expect(validateProject(mockStartTransformationRequest, mockedLogging)).to.equal(true) - }) - - it('should not include AspNetWebForms in unsupported projects list', () => { - let mockStartTransformationRequest: StartTransformRequest = sampleStartTransformRequest - - // Add a supported project - const supportedProjectMeta = { - Name: 'Supported', - ProjectTargetFramework: '', - ProjectPath: 'supported.csproj', - SourceCodeFilePaths: [], - ProjectLanguage: '', - ProjectType: 'AspNetCoreMvc', - ExternalReferences: [], - } - - // Add an unsupported project - const unsupportedProjectMeta = { - Name: 'Unsupported', - ProjectTargetFramework: '', - ProjectPath: 'unsupported.csproj', - SourceCodeFilePaths: [], - ProjectLanguage: '', - ProjectType: 'UnsupportedType', - ExternalReferences: [], - } - - // Add an AspNetWebForms project - const webFormsProjectMeta = { - Name: 'WebForms', - ProjectTargetFramework: '', - ProjectPath: 'webforms.csproj', - SourceCodeFilePaths: [], - ProjectLanguage: '', - ProjectType: 'AspNetWebForms', - ExternalReferences: [], - } - - mockStartTransformationRequest.ProjectMetadata = [ - supportedProjectMeta, - unsupportedProjectMeta, - webFormsProjectMeta, - ] - - const unsupportedProjects = validateSolution(mockStartTransformationRequest) - - // Should only contain the unsupported project, not the AspNetWebForms project - expect(unsupportedProjects).to.have.lengthOf(1) - expect(unsupportedProjects[0]).to.equal('unsupported.csproj') - expect(unsupportedProjects).to.not.include('webforms.csproj') - }) }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/transformHandler.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/transformHandler.ts index b0afaa06f1..b8bc80b30c 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/transformHandler.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/transformHandler.ts @@ -54,18 +54,6 @@ export class TransformHandler { isProject, this.logging ) - if (isProject) { - let isValid = validation.validateProject(userInputrequest, this.logging) - if (!isValid) { - return { - Error: 'NotSupported', - IsSupported: false, - ContainsUnsupportedViews: containsUnsupportedViews, - } as StartTransformResponse - } - } else { - unsupportedProjects = validation.validateSolution(userInputrequest) - } const artifactManager = new ArtifactManager( this.workspace, diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/validation.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/validation.ts index 0d244e09a8..efb443fb0f 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/validation.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/validation.ts @@ -6,9 +6,7 @@ import { TransformationJob } from '../../client/token/codewhispererbearertokencl import { TransformationErrorCode } from './models' /** - * TEMPORARY HACK: AspNetWebForms project type is allowed in validateProject and validateSolution - * functions without being added to the supportedProjects array. This is to enable WebForms to Blazor - * transformation without officially supporting it yet. + * Project type validation moved to backend service. */ export function isProject(userInputrequest: StartTransformRequest): boolean { @@ -19,32 +17,6 @@ export function isSolution(userInputrequest: StartTransformRequest): boolean { return userInputrequest.SelectedProjectPath.endsWith('.sln') } -export function validateProject(userInputrequest: StartTransformRequest, logging: Logging): boolean { - var selectedProject = userInputrequest.ProjectMetadata.find( - project => project.ProjectPath == userInputrequest.SelectedProjectPath - ) - - if (selectedProject) { - // Temporary hack: Allow AspNetWebForms project type without adding it to supportedProjects - var isValid = - supportedProjects.includes(selectedProject?.ProjectType) || - selectedProject?.ProjectType === 'AspNetWebForms' - logging.log( - `Selected project ${userInputrequest?.SelectedProjectPath} has project type ${selectedProject.ProjectType}` + - (isValid ? '' : ' that is not supported') - ) - return isValid - } - logging.log(`Error occured in verifying selected project with path ${userInputrequest.SelectedProjectPath}`) - return false -} - -export function validateSolution(userInputrequest: StartTransformRequest): string[] { - return userInputrequest.ProjectMetadata.filter( - project => !supportedProjects.includes(project.ProjectType) && project.ProjectType !== 'AspNetWebForms' - ).map(project => project.ProjectPath) -} - export async function checkForUnsupportedViews( userInputRequest: StartTransformRequest, isProject: boolean, diff --git a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/IdleWorkspaceManager.test.ts b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/IdleWorkspaceManager.test.ts new file mode 100644 index 0000000000..a5d81526c9 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/IdleWorkspaceManager.test.ts @@ -0,0 +1,75 @@ +import { IdleWorkspaceManager } from './IdleWorkspaceManager' +import { WorkspaceFolderManager } from './workspaceFolderManager' +import sinon, { stubInterface, StubbedInstance } from 'ts-sinon' + +describe('IdleWorkspaceManager', () => { + let clock: sinon.SinonFakeTimers + let mockWorkspaceFolderManager: StubbedInstance + + beforeEach(() => { + clock = sinon.useFakeTimers() + mockWorkspaceFolderManager = stubInterface() + sinon.stub(WorkspaceFolderManager, 'getInstance').returns(mockWorkspaceFolderManager) + sinon.stub(console, 'error') + }) + + afterEach(() => { + clock.restore() + sinon.restore() + }) + + describe('isSessionIdle', () => { + it('should return false when session is not idle', () => { + IdleWorkspaceManager.recordActivityTimestamp() + + const result = IdleWorkspaceManager.isSessionIdle() + + expect(result).toBe(false) + }) + + it('should return true when session exceeds idle threshold', () => { + IdleWorkspaceManager.recordActivityTimestamp() + clock.tick(31 * 60 * 1000) // 31 minutes + + const result = IdleWorkspaceManager.isSessionIdle() + + expect(result).toBe(true) + }) + }) + + describe('recordActivityTimestamp', () => { + it('should update activity timestamp', async () => { + IdleWorkspaceManager.recordActivityTimestamp() + + expect(IdleWorkspaceManager.isSessionIdle()).toBe(false) + }) + + it('should not trigger workspace check when session was not idle', async () => { + mockWorkspaceFolderManager.isContinuousMonitoringStopped.returns(false) + + IdleWorkspaceManager.recordActivityTimestamp() + + sinon.assert.notCalled(mockWorkspaceFolderManager.checkRemoteWorkspaceStatusAndReact) + }) + + it('should trigger workspace check when session was idle and monitoring is active', async () => { + // Make session idle first + clock.tick(31 * 60 * 1000) + mockWorkspaceFolderManager.isContinuousMonitoringStopped.returns(false) + mockWorkspaceFolderManager.checkRemoteWorkspaceStatusAndReact.resolves() + + IdleWorkspaceManager.recordActivityTimestamp() + + sinon.assert.calledOnce(mockWorkspaceFolderManager.checkRemoteWorkspaceStatusAndReact) + }) + + it('should not trigger workspace check when session was idle but monitoring is stopped', async () => { + clock.tick(31 * 60 * 1000) + mockWorkspaceFolderManager.isContinuousMonitoringStopped.returns(true) + + IdleWorkspaceManager.recordActivityTimestamp() + + sinon.assert.notCalled(mockWorkspaceFolderManager.checkRemoteWorkspaceStatusAndReact) + }) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/IdleWorkspaceManager.ts b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/IdleWorkspaceManager.ts new file mode 100644 index 0000000000..1e27a7f762 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/IdleWorkspaceManager.ts @@ -0,0 +1,42 @@ +import { WorkspaceFolderManager } from './workspaceFolderManager' + +export class IdleWorkspaceManager { + private static readonly idleThreshold = 30 * 60 * 1000 // 30 minutes + private static lastActivityTimestamp = 0 // treat session as idle at the start + + private constructor() {} + + /** + * Records activity timestamp and triggers workspace status check if session was idle. + * + * When transitioning from idle to active, proactively checks remote workspace status + * (if continuous monitoring is enabled) without blocking the current operation. + */ + public static recordActivityTimestamp(): void { + try { + const wasSessionIdle = IdleWorkspaceManager.isSessionIdle() + IdleWorkspaceManager.lastActivityTimestamp = Date.now() + + const workspaceFolderManager = WorkspaceFolderManager.getInstance() + if (workspaceFolderManager && wasSessionIdle && !workspaceFolderManager.isContinuousMonitoringStopped()) { + // Proactively check the remote workspace status instead of waiting for the next scheduled check + // Fire and forget - don't await to avoid blocking + workspaceFolderManager.checkRemoteWorkspaceStatusAndReact().catch(err => { + // ignore errors + }) + } + } catch (err) { + // ignore errors + } + } + + public static setSessionAsIdle(): void { + IdleWorkspaceManager.lastActivityTimestamp = 0 + } + + public static isSessionIdle(): boolean { + const currentTime = Date.now() + const timeSinceLastActivity = currentTime - IdleWorkspaceManager.lastActivityTimestamp + return timeSinceLastActivity > IdleWorkspaceManager.idleThreshold + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/artifactManager.ts b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/artifactManager.ts index 9518f9629a..dfedb0c913 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/artifactManager.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/artifactManager.ts @@ -694,6 +694,21 @@ export class ArtifactManager { additionalFiles.push(...updatedFiles) } + // Fallback methods to generate a .classpath at workspace folder root + if (projectRoots.length == 0) { + const javaManager = new JavaProjectAnalyzer(workspacePath) + const structure = await javaManager.analyze() + const generator = new EclipseConfigGenerator(workspaceFolder, this.logging) + const classpathFiles = await generator.generateDotClasspath(structure) + for (const classpathFile of classpathFiles) { + additionalFiles.push(classpathFile) + } + const projectFiles = await generator.generateDotProject(path.basename(workspacePath), structure) + for (const projectFile of projectFiles) { + additionalFiles.push(projectFile) + } + } + return [...files, ...additionalFiles] } diff --git a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/dependency/dependencyDiscoverer.ts b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/dependency/dependencyDiscoverer.ts index 8a4a9755cb..ed387ca2ca 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/dependency/dependencyDiscoverer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/dependency/dependencyDiscoverer.ts @@ -191,7 +191,8 @@ export class DependencyDiscoverer { } } - public resetFromDisposal(): void { + public disposeAndReset(): void { + this.dispose() this.sharedState.isDisposed = false this.sharedState.dependencyUploadedSizeSum = 0 } diff --git a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceContextServer.ts b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceContextServer.ts index 09336ffb2f..92dc0823e3 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceContextServer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceContextServer.ts @@ -188,7 +188,12 @@ export const WorkspaceContextServer = (): Server => features => { abTestingEnabled = true } else { const clientParams = safeGet(lsp.getClientInitializeParams()) - const userContext = makeUserContextObject(clientParams, runtime.platform, 'CodeWhisperer') ?? { + const userContext = makeUserContextObject( + clientParams, + runtime.platform, + 'CodeWhisperer', + amazonQServiceManager.serverInfo + ) ?? { ideCategory: 'VSCODE', operatingSystem: 'MAC', product: 'CodeWhisperer', @@ -221,7 +226,7 @@ export const WorkspaceContextServer = (): Server => features => { isLoggedInUsingBearerToken(credentialsProvider) && abTestingEnabled && !workspaceFolderManager.getOptOutStatus() && - !workspaceFolderManager.getServiceQuotaExceededStatus() && + !workspaceFolderManager.isFeatureDisabled() && workspaceIdentifier ) } @@ -303,17 +308,15 @@ export const WorkspaceContextServer = (): Server => features => { await evaluateABTesting() isWorkflowInitialized = true - workspaceFolderManager.resetAdminOptOutAndServiceQuotaStatus() + workspaceFolderManager.resetAdminOptOutAndFeatureDisabledStatus() if (!isUserEligibleForWorkspaceContext()) { return } fileUploadJobManager.startFileUploadJobConsumer() dependencyEventBundler.startDependencyEventBundler() - await Promise.all([ - workspaceFolderManager.initializeWorkspaceStatusMonitor(), - workspaceFolderManager.processNewWorkspaceFolders(workspaceFolders), - ]) + + workspaceFolderManager.initializeWorkspaceStatusMonitor() logging.log(`Workspace context workflow initialized`) } else if (!isLoggedIn) { if (isWorkflowInitialized) { diff --git a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.test.ts b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.test.ts index 0b4ec53f25..cbdb71db1d 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.test.ts @@ -6,7 +6,8 @@ import { DependencyDiscoverer } from './dependency/dependencyDiscoverer' import { WorkspaceFolder } from 'vscode-languageserver-protocol' import { ArtifactManager } from './artifactManager' import { CodeWhispererServiceToken } from '../../shared/codeWhispererService' -import { CreateWorkspaceResponse } from '../../client/token/codewhispererbearertokenclient' +import { ListWorkspaceMetadataResponse } from '../../client/token/codewhispererbearertokenclient' +import { IdleWorkspaceManager } from './IdleWorkspaceManager' import { AWSError } from 'aws-sdk' describe('WorkspaceFolderManager', () => { @@ -33,8 +34,8 @@ describe('WorkspaceFolderManager', () => { sinon.restore() }) - describe('getServiceQuotaExceededStatus', () => { - it('should return true when service quota is exceeded', async () => { + describe('checkRemoteWorkspaceStatusAndReact', () => { + it('should check and react when IDE session is not idle', async () => { // Setup const workspaceFolders: WorkspaceFolder[] = [ { @@ -43,19 +44,122 @@ describe('WorkspaceFolderManager', () => { }, ] - // Mock the createWorkspace method to throw a ServiceQuotaExceededException + // Mock IdleSessionManager to return false (not idle) + sinon.stub(IdleWorkspaceManager, 'isSessionIdle').returns(false) + + // Mock successful response + const mockResponse: ListWorkspaceMetadataResponse = { + workspaces: [ + { + workspaceId: 'test-workspace-id', + workspaceStatus: 'CREATED', + }, + ], + } + + mockCodeWhispererService.listWorkspaceMetadata.resolves(mockResponse as any) + + // Create the WorkspaceFolderManager instance using the static createInstance method + workspaceFolderManager = WorkspaceFolderManager.createInstance( + mockServiceManager, + mockLogging, + mockArtifactManager, + mockDependencyDiscoverer, + workspaceFolders, + mockCredentialsProvider, + 'test-workspace-identifier' + ) + + // Spy on resetWebSocketClient + const resetWebSocketClientSpy = sinon.stub(workspaceFolderManager as any, 'resetWebSocketClient') + + // Spy on handleWorkspaceCreatedState + const handleWorkspaceCreatedStateSpy = sinon.stub( + workspaceFolderManager as any, + 'handleWorkspaceCreatedState' + ) + + // Act - trigger the checkRemoteWorkspaceStatusAndReact method + await workspaceFolderManager.checkRemoteWorkspaceStatusAndReact() + + // Verify that resetWebSocketClient was called once + sinon.assert.notCalled(resetWebSocketClientSpy) + sinon.assert.calledOnce(handleWorkspaceCreatedStateSpy) + }) + + it('should skip checking and reacting when IDE session is idle', async () => { + // Setup + const workspaceFolders: WorkspaceFolder[] = [ + { + uri: 'file:///test/workspace', + name: 'test-workspace', + }, + ] + + // Mock IdleSessionManager to return true (idle) + sinon.stub(IdleWorkspaceManager, 'isSessionIdle').returns(true) + + // Mock successful response + const mockResponse: ListWorkspaceMetadataResponse = { + workspaces: [ + { + workspaceId: 'test-workspace-id', + workspaceStatus: 'CREATED', + }, + ], + } + + mockCodeWhispererService.listWorkspaceMetadata.resolves(mockResponse as any) + + // Create the WorkspaceFolderManager instance using the static createInstance method + workspaceFolderManager = WorkspaceFolderManager.createInstance( + mockServiceManager, + mockLogging, + mockArtifactManager, + mockDependencyDiscoverer, + workspaceFolders, + mockCredentialsProvider, + 'test-workspace-identifier' + ) + + // Spy on resetWebSocketClient + const resetWebSocketClientSpy = sinon.stub(workspaceFolderManager as any, 'resetWebSocketClient') + + // Act - trigger the checkRemoteWorkspaceStatusAndReact method + await workspaceFolderManager.checkRemoteWorkspaceStatusAndReact() + + // Verify that resetWebSocketClient was called once + sinon.assert.calledOnce(resetWebSocketClientSpy) + sinon.assert.calledWith( + mockLogging.log, + sinon.match(/Session is idle, skipping remote workspace status check/) + ) + }) + }) + + describe('isFeatureDisabled', () => { + it('should return true when feature is disabled', async () => { + // Setup + const workspaceFolders: WorkspaceFolder[] = [ + { + uri: 'file:///test/workspace', + name: 'test-workspace', + }, + ] + + // Mock listWorkspaceMetadata to throw AccessDeniedException with feature not supported const mockError: AWSError = { - name: 'ServiceQuotaExceededException', - message: 'You have too many active running workspaces.', - code: 'ServiceQuotaExceededException', + name: 'AccessDeniedException', + message: 'Feature is not supported', + code: 'AccessDeniedException', time: new Date(), retryable: false, - statusCode: 400, + statusCode: 403, } - mockCodeWhispererService.createWorkspace.rejects(mockError) + mockCodeWhispererService.listWorkspaceMetadata.rejects(mockError) - // Create the WorkspaceFolderManager instance using the static createInstance method + // Create the WorkspaceFolderManager instance workspaceFolderManager = WorkspaceFolderManager.createInstance( mockServiceManager, mockLogging, @@ -72,17 +176,17 @@ describe('WorkspaceFolderManager', () => { 'clearAllWorkspaceResources' ) - // Act - trigger the createNewWorkspace method which sets isServiceQuotaExceeded - await (workspaceFolderManager as any).createNewWorkspace() + // Act - trigger listWorkspaceMetadata which sets feature disabled state + await (workspaceFolderManager as any).listWorkspaceMetadata() // Assert - expect(workspaceFolderManager.getServiceQuotaExceededStatus()).toBe(true) + expect(workspaceFolderManager.isFeatureDisabled()).toBe(true) // Verify that clearAllWorkspaceResources was called sinon.assert.calledOnce(clearAllWorkspaceResourcesSpy) }) - it('should return false when service quota is not exceeded', async () => { + it('should return false when feature is not disabled', async () => { // Setup const workspaceFolders: WorkspaceFolder[] = [ { @@ -92,16 +196,18 @@ describe('WorkspaceFolderManager', () => { ] // Mock successful response - const mockResponse: CreateWorkspaceResponse = { - workspace: { - workspaceId: 'test-workspace-id', - workspaceStatus: 'RUNNING', - }, + const mockResponse: ListWorkspaceMetadataResponse = { + workspaces: [ + { + workspaceId: 'test-workspace-id', + workspaceStatus: 'RUNNING', + }, + ], } - mockCodeWhispererService.createWorkspace.resolves(mockResponse as any) + mockCodeWhispererService.listWorkspaceMetadata.resolves(mockResponse as any) - // Create the WorkspaceFolderManager instance using the static createInstance method + // Create the WorkspaceFolderManager instance workspaceFolderManager = WorkspaceFolderManager.createInstance( mockServiceManager, mockLogging, @@ -112,20 +218,11 @@ describe('WorkspaceFolderManager', () => { 'test-workspace-identifier' ) - // Spy on clearAllWorkspaceResources - const clearAllWorkspaceResourcesSpy = sinon.stub( - workspaceFolderManager as any, - 'clearAllWorkspaceResources' - ) - - // Act - trigger the createNewWorkspace method - await (workspaceFolderManager as any).createNewWorkspace() + // Act - trigger listWorkspaceMetadata + await (workspaceFolderManager as any).listWorkspaceMetadata() // Assert - expect(workspaceFolderManager.getServiceQuotaExceededStatus()).toBe(false) - - // Verify that clearAllWorkspaceResources was not called - sinon.assert.notCalled(clearAllWorkspaceResourcesSpy) + expect(workspaceFolderManager.isFeatureDisabled()).toBe(false) }) }) }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.ts b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.ts index 00048cd62f..1644851258 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.ts @@ -20,6 +20,7 @@ import { AmazonQTokenServiceManager } from '../../shared/amazonQServiceManager/A import { URI } from 'vscode-uri' import path = require('path') import { isAwsError } from '../../shared/utils' +import { IdleWorkspaceManager } from './IdleWorkspaceManager' interface WorkspaceState { remoteWorkspaceState: WorkspaceStatus @@ -54,8 +55,9 @@ export class WorkspaceFolderManager { private optOutMonitorInterval: NodeJS.Timeout | undefined private messageQueueConsumerInterval: NodeJS.Timeout | undefined private isOptedOut: boolean = false - // Tracks if the user has reached their maximum allowed remote workspaces quota - private isServiceQuotaExceeded: boolean = false + private featureDisabled: boolean = false // Serve as a server-side control. If true, stop WCS features + private isCheckingRemoteWorkspaceStatus: boolean = false + private isArtifactUploadedToRemoteWorkspace: boolean = false static createInstance( serviceManager: AmazonQTokenServiceManager, @@ -138,13 +140,13 @@ export class WorkspaceFolderManager { return this.isOptedOut } - getServiceQuotaExceededStatus(): boolean { - return this.isServiceQuotaExceeded + resetAdminOptOutAndFeatureDisabledStatus(): void { + this.isOptedOut = false + this.featureDisabled = false } - resetAdminOptOutAndServiceQuotaStatus(): void { - this.isOptedOut = false - this.isServiceQuotaExceeded = false + isFeatureDisabled(): boolean { + return this.featureDisabled } getWorkspaceState(): WorkspaceState { @@ -324,14 +326,14 @@ export class WorkspaceFolderManager { this.workspaceState.webSocketClient = webSocketClient } - async initializeWorkspaceStatusMonitor() { + initializeWorkspaceStatusMonitor() { this.logging.log(`Initializing workspace status check for workspace [${this.workspaceIdentifier}]`) // Reset workspace ID to force operations to wait for new remote workspace information this.resetRemoteWorkspaceId() - this.artifactManager.resetFromDisposal() - this.dependencyDiscoverer.resetFromDisposal() + IdleWorkspaceManager.setSessionAsIdle() + this.isArtifactUploadedToRemoteWorkspace = false // Set up message queue consumer if (this.messageQueueConsumerInterval === undefined) { @@ -350,12 +352,8 @@ export class WorkspaceFolderManager { }, this.MESSAGE_PUBLISH_INTERVAL) } - // Perform a one-time checkRemoteWorkspaceStatusAndReact first - // Pass skipUploads as true since it would be handled by processNewWorkspaceFolders - await this.checkRemoteWorkspaceStatusAndReact(true) - // Set up continuous monitoring which periodically invokes checkRemoteWorkspaceStatusAndReact - if (!this.isOptedOut && !this.isServiceQuotaExceeded && this.continuousMonitorInterval === undefined) { + if (!this.isOptedOut && this.continuousMonitorInterval === undefined) { this.logging.log(`Starting continuous monitor for workspace [${this.workspaceIdentifier}]`) this.continuousMonitorInterval = setInterval(async () => { try { @@ -380,7 +378,9 @@ export class WorkspaceFolderManager { return resolve(false) } - const { metadata, optOut } = await this.listWorkspaceMetadata(this.workspaceIdentifier) + const { metadata, optOut, featureDisabled } = await this.listWorkspaceMetadata( + this.workspaceIdentifier + ) if (optOut) { this.logging.log(`User opted out during initial connection`) @@ -390,6 +390,13 @@ export class WorkspaceFolderManager { return resolve(false) } + if (featureDisabled) { + this.logging.log(`Feature disabled during initial connection`) + this.featureDisabled = true + this.clearAllWorkspaceResources() + return resolve(false) + } + if (!metadata) { // Continue polling by exiting only this iteration return @@ -427,61 +434,101 @@ export class WorkspaceFolderManager { }) } - private async checkRemoteWorkspaceStatusAndReact(skipUploads: boolean = false) { - if (this.workspaceFolders.length === 0) { - this.logging.log(`No workspace folders added, skipping workspace status check`) + public async checkRemoteWorkspaceStatusAndReact() { + if (this.isCheckingRemoteWorkspaceStatus) { + // Skip checking remote workspace if a previous check is still in progress return } + this.isCheckingRemoteWorkspaceStatus = true + try { + if (IdleWorkspaceManager.isSessionIdle()) { + this.resetWebSocketClient() + this.logging.log('Session is idle, skipping remote workspace status check') + return + } - this.logging.log(`Checking remote workspace status for workspace [${this.workspaceIdentifier}]`) - const { metadata, optOut, error } = await this.listWorkspaceMetadata(this.workspaceIdentifier) + if (this.workspaceFolders.length === 0) { + this.logging.log(`No workspace folders added, skipping workspace status check`) + return + } - if (optOut) { - this.logging.log('User opted out, clearing all resources and starting opt-out monitor') - this.isOptedOut = true - this.clearAllWorkspaceResources() - this.startOptOutMonitor() - return - } + this.logging.log(`Checking remote workspace status for workspace [${this.workspaceIdentifier}]`) + const { metadata, optOut, featureDisabled, error } = await this.listWorkspaceMetadata( + this.workspaceIdentifier + ) - if (error) { - // Do not do anything if we received an exception but not caused by optOut - return - } + if (optOut) { + this.logging.log('User opted out, clearing all resources and starting opt-out monitor') + this.isOptedOut = true + this.clearAllWorkspaceResources() + this.startOptOutMonitor() + return + } - if (!metadata) { - // Workspace no longer exists, Recreate it. - this.resetRemoteWorkspaceId() // workspaceId would change if remote record is gone - await this.handleWorkspaceCreatedState(skipUploads) - return - } + if (featureDisabled) { + this.logging.log('Feature disabled, clearing all resources and stoping server-side indexing features') + this.featureDisabled = true + this.clearAllWorkspaceResources() + return + } - this.workspaceState.remoteWorkspaceState = metadata.workspaceStatus - if (this.workspaceState.workspaceId === undefined) { - this.setRemoteWorkspaceId(metadata.workspaceId) - } + if (error) { + // Do not do anything if we received an exception but not caused by optOut + return + } - switch (metadata.workspaceStatus) { - case 'READY': - // Check if connection exists - const client = this.workspaceState.webSocketClient - if (!client || !client.isConnected()) { - this.logging.log( - `Workspace is ready but no connection exists or connection lost. Re-establishing connection...` - ) - await this.establishConnection(metadata) - } - break - case 'PENDING': - // Schedule an initial connection when pending - await this.waitForInitialConnection() - break - case 'CREATED': - // Workspace has no environment, Recreate it. - await this.handleWorkspaceCreatedState(skipUploads) - break - default: - this.logging.warn(`Unknown workspace status: ${metadata.workspaceStatus}`) + if (!metadata) { + // Workspace no longer exists, Recreate it. + this.resetRemoteWorkspaceId() // workspaceId would change if remote record is gone + await this.handleWorkspaceCreatedState() + return + } + + this.workspaceState.remoteWorkspaceState = metadata.workspaceStatus + if (this.workspaceState.workspaceId === undefined) { + this.setRemoteWorkspaceId(metadata.workspaceId) + } + + switch (metadata.workspaceStatus) { + case 'READY': + // Check if connection exists + const client = this.workspaceState.webSocketClient + if (!client || !client.isConnected()) { + this.logging.log( + `Workspace is ready but no connection exists or connection lost. Re-establishing connection...` + ) + let uploadArtifactsPromise: Promise | undefined + if (!this.isArtifactUploadedToRemoteWorkspace) { + uploadArtifactsPromise = this.uploadAllArtifactsToRemoteWorkspace() + } + await this.establishConnection(metadata) + if (uploadArtifactsPromise) { + await uploadArtifactsPromise + } + } + break + case 'PENDING': + // Schedule an initial connection when pending + let uploadArtifactsPromise: Promise | undefined + if (!this.isArtifactUploadedToRemoteWorkspace) { + uploadArtifactsPromise = this.uploadAllArtifactsToRemoteWorkspace() + } + await this.waitForInitialConnection() + if (uploadArtifactsPromise) { + await uploadArtifactsPromise + } + break + case 'CREATED': + // Workspace has no environment, Recreate it. + await this.handleWorkspaceCreatedState() + break + default: + this.logging.warn(`Unknown workspace status: ${metadata.workspaceStatus}`) + } + } catch (error) { + this.logging.error(`Error checking remote workspace status: ${error}`) + } finally { + this.isCheckingRemoteWorkspaceStatus = false } } @@ -506,7 +553,14 @@ export class WorkspaceFolderManager { if (this.optOutMonitorInterval === undefined) { const intervalId = setInterval(async () => { try { - const { optOut } = await this.listWorkspaceMetadata() + const { optOut, featureDisabled } = await this.listWorkspaceMetadata() + + if (featureDisabled) { + // Stop opt-out monitor when WCS feature is disabled from server-side + this.featureDisabled = true + clearInterval(intervalId) + this.optOutMonitorInterval = undefined + } if (!optOut) { this.isOptedOut = false @@ -515,9 +569,7 @@ export class WorkspaceFolderManager { ) clearInterval(intervalId) this.optOutMonitorInterval = undefined - this.initializeWorkspaceStatusMonitor().catch(error => { - this.logging.error(`Error while initializing workspace status monitoring: ${error}`) - }) + this.initializeWorkspaceStatusMonitor() this.processNewWorkspaceFolders(this.workspaceFolders).catch(error => { this.logging.error(`Error while processing workspace folders: ${error}`) }) @@ -530,25 +582,18 @@ export class WorkspaceFolderManager { } } - private async handleWorkspaceCreatedState(skipUploads: boolean = false): Promise { + private async handleWorkspaceCreatedState(): Promise { this.logging.log(`No READY / PENDING remote workspace found, creating a new one`) // If remote state is CREATED, call create API to create a new workspace - if (this.workspaceState.webSocketClient) { - this.workspaceState.webSocketClient.destroyClient() - this.workspaceState.webSocketClient = undefined - } + this.resetWebSocketClient() const initialResult = await this.createNewWorkspace() // If creation succeeds, establish connection if (initialResult.response) { this.logging.log(`Workspace [${this.workspaceIdentifier}] created successfully, establishing connection`) + const uploadArtifactsPromise = this.uploadAllArtifactsToRemoteWorkspace() await this.waitForInitialConnection() - if (!skipUploads) { - await this.syncSourceCodesToS3(this.workspaceFolders) - this.dependencyDiscoverer.reSyncDependenciesToS3(this.workspaceFolders).catch(e => { - this.logging.warn(`Error during re-syncing dependencies: ${e}`) - }) - } + await uploadArtifactsPromise return } @@ -570,13 +615,27 @@ export class WorkspaceFolderManager { } this.logging.log(`Retry succeeded for workspace creation, establishing connection`) + const uploadArtifactsPromise = this.uploadAllArtifactsToRemoteWorkspace() await this.waitForInitialConnection() - if (!skipUploads) { - await this.syncSourceCodesToS3(this.workspaceFolders) - this.dependencyDiscoverer.reSyncDependenciesToS3(this.workspaceFolders).catch(e => { - this.logging.warn(`Error during re-syncing dependencies: ${e}`) - }) - } + await uploadArtifactsPromise + } + + private async uploadAllArtifactsToRemoteWorkspace() { + // initialize source codes + this.artifactManager.resetFromDisposal() + await this.syncSourceCodesToS3(this.workspaceFolders) + + // initialize dependencies + this.dependencyDiscoverer.disposeAndReset() + this.dependencyDiscoverer.searchDependencies(this.workspaceFolders).catch(e => { + this.logging.warn(`Error during dependency discovery: ${e}`) + }) + + this.isArtifactUploadedToRemoteWorkspace = true + } + + public isContinuousMonitoringStopped(): boolean { + return this.continuousMonitorInterval === undefined } private stopContinuousMonitoring() { @@ -602,15 +661,15 @@ export class WorkspaceFolderManager { } } - private async createNewWorkspace() { - const createWorkspaceResult = await this.createWorkspace(this.workspaceIdentifier) - - this.isServiceQuotaExceeded = createWorkspaceResult.isServiceQuotaExceeded - if (this.isServiceQuotaExceeded) { - // Stop continuous monitor and all actions - this.clearAllWorkspaceResources() + private resetWebSocketClient() { + if (this.workspaceState.webSocketClient) { + this.workspaceState.webSocketClient.destroyClient() + this.workspaceState.webSocketClient = undefined } + } + private async createNewWorkspace() { + const createWorkspaceResult = await this.createWorkspace(this.workspaceIdentifier) const workspaceDetails = createWorkspaceResult.response if (!workspaceDetails) { this.logging.warn(`Failed to create remote workspace for [${this.workspaceIdentifier}]`) @@ -708,10 +767,12 @@ export class WorkspaceFolderManager { private async listWorkspaceMetadata(workspaceRoot?: WorkspaceRoot): Promise<{ metadata: WorkspaceMetadata | undefined | null optOut: boolean + featureDisabled: boolean error: any }> { let metadata: WorkspaceMetadata | undefined | null let optOut = false + let featureDisabled = false let error: any try { const params = workspaceRoot ? { workspaceRoot } : {} @@ -727,8 +788,11 @@ export class WorkspaceFolderManager { this.logging.log(`User's administrator opted out server-side workspace context`) optOut = true } + if (isAwsError(e) && e.code === 'AccessDeniedException' && e.message.includes('Feature is not supported')) { + featureDisabled = true + } } - return { metadata, optOut, error } + return { metadata, optOut, featureDisabled, error } } private async createWorkspace(workspaceRoot: WorkspaceRoot): Promise<{ diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts index c418fe29ca..df144e72d6 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts @@ -34,6 +34,7 @@ import { getUserAgent } from '../telemetryUtils' import { StreamingClientServiceToken } from '../streamingClientService' import { parse } from '@aws-sdk/util-arn-parser' import { ChatDatabase } from '../../language-server/agenticChat/tools/chatDb/chatDb' +import { ProfileStatusMonitor } from '../../language-server/agenticChat/tools/mcp/profileStatusMonitor' /** * AmazonQTokenServiceManager manages state and provides centralized access to @@ -157,6 +158,9 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager< this.resetCodewhispererService() this.connectionType = 'none' this.state = 'PENDING_CONNECTION' + + // Reset MCP state cache when auth changes + ProfileStatusMonitor.resetMcpState() } public async handleOnUpdateConfiguration(params: UpdateConfigurationParams, _token: CancellationToken) { @@ -250,6 +254,9 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager< this.state = 'INITIALIZED' this.log('Initialized Amazon Q service with builderId connection') + // Emit auth success event + ProfileStatusMonitor.emitAuthSuccess() + return } @@ -272,6 +279,9 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager< this.state = 'INITIALIZED' this.log('Initialized Amazon Q service with identityCenter connection') + // Emit auth success event + ProfileStatusMonitor.emitAuthSuccess() + return } @@ -380,6 +390,9 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager< `Initialized identityCenter connection to region ${newProfile.identityDetails.region} for profile ${newProfile.arn}` ) + // Emit auth success event + ProfileStatusMonitor.emitAuthSuccess() + return } @@ -390,6 +403,9 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager< this.activeIdcProfile = newProfile this.state = 'INITIALIZED' + // Emit auth success event + ProfileStatusMonitor.emitAuthSuccess() + return } @@ -433,6 +449,9 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager< ) this.state = 'INITIALIZED' + // Emit auth success event + ProfileStatusMonitor.emitAuthSuccess() + return } diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.ts index 9c241809a7..cc21cd8766 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.ts @@ -17,6 +17,7 @@ import { } from './configurationUtils' import { AmazonQServiceInitializationError } from './errors' import { StreamingClientServiceBase } from '../streamingClientService' +import { UserContext } from '../../client/token/codewhispererbearertokenclient' export interface QServiceManagerFeatures { lsp: Lsp @@ -86,6 +87,10 @@ export abstract class BaseAmazonQServiceManager< abstract getCodewhispererService(): C abstract getStreamingClient(): S + get serverInfo() { + return this.features.runtime.serverInfo + } + public getConfiguration(): Readonly { return this.configurationCache.getConfig() } diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts index 63ad6556e7..187bc41f35 100644 --- a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts @@ -9,6 +9,10 @@ import { Workspace, Logging, SDKInitializator, + TextDocument, + Position, + CancellationToken, + InlineCompletionWithReferencesParams, } from '@aws/language-server-runtimes/server-interface' import { ConfigurationOptions } from 'aws-sdk' import * as sinon from 'sinon' @@ -20,6 +24,9 @@ import { GenerateSuggestionsRequest, GenerateSuggestionsResponse, } from './codeWhispererService' +import { RecentEditTracker } from '../language-server/inline-completion/tracker/codeEditTracker' +import { CodeWhispererSupplementalContext } from './models/model' +import CodeWhispererTokenClient = require('../client/token/codewhispererbearertokenclient') describe('CodeWhispererService', function () { let sandbox: sinon.SinonSandbox @@ -71,6 +78,25 @@ describe('CodeWhispererService', function () { return 'iam' } + async constructSupplementalContext( + document: TextDocument, + position: Position, + workspace: Workspace, + recentEditTracker: RecentEditTracker, + logging: Logging, + cancellationToken: CancellationToken, + opentabs: InlineCompletionWithReferencesParams['openTabFilepaths'], + config: { includeRecentEdits: boolean } + ): Promise< + | { + supContextData: CodeWhispererSupplementalContext + items: CodeWhispererTokenClient.SupplementalContextList + } + | undefined + > { + return undefined + } + // Add public getters for protected properties get testCodeWhispererRegion() { return this.codeWhispererRegion diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts index d1db107014..2304d81eea 100644 --- a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts @@ -10,6 +10,7 @@ import { TextDocument, Position, WorkspaceFolder, + InlineCompletionWithReferencesParams, } from '@aws/language-server-runtimes/server-interface' import { waitUntil } from '@aws/lsp-core/out/util/timeoutUtils' import { AWSError, ConfigurationOptions, CredentialProviderChain, Credentials } from 'aws-sdk' @@ -29,8 +30,11 @@ import CodeWhispererSigv4Client = require('../client/sigv4/codewhisperersigv4cli import CodeWhispererTokenClient = require('../client/token/codewhispererbearertokenclient') import { getErrorId } from './utils' import { GenerateCompletionsResponse } from '../client/token/codewhispererbearertokenclient' -import { CodewhispererLanguage, getRuntimeLanguage } from './languageDetection' import { getRelativePath } from '../language-server/workspaceContext/util' +import { CodewhispererLanguage, getRuntimeLanguage } from './languageDetection' +import { RecentEditTracker } from '../language-server/inline-completion/tracker/codeEditTracker' +import { CodeWhispererSupplementalContext } from './models/model' +import { fetchSupplementalContext } from './supplementalContextUtil/supplementalContextUtil' import * as path from 'path' import { CONTEXT_CHARACTERS_LIMIT, @@ -67,20 +71,22 @@ export interface GenerateSuggestionsResponse { responseContext: ResponseContext } +export interface ClientFileContext { + leftFileContent: string + rightFileContent: string + filename: string + fileUri: string + programmingLanguage: { + languageName: CodewhispererLanguage + } +} + export function getFileContext(params: { textDocument: TextDocument position: Position inferredLanguageId: CodewhispererLanguage workspaceFolder: WorkspaceFolder | null | undefined -}): { - fileUri: string - filename: string - programmingLanguage: { - languageName: CodewhispererLanguage - } - leftFileContent: string - rightFileContent: string -} { +}): ClientFileContext { const left = params.textDocument.getText({ start: { line: 0, character: 0 }, end: params.position, @@ -138,6 +144,23 @@ export abstract class CodeWhispererServiceBase { abstract generateSuggestions(request: GenerateSuggestionsRequest): Promise + abstract constructSupplementalContext( + document: TextDocument, + position: Position, + workspace: Workspace, + recentEditTracker: RecentEditTracker, + logging: Logging, + cancellationToken: CancellationToken, + opentabs: InlineCompletionWithReferencesParams['openTabFilepaths'], + config: { includeRecentEdits: boolean } + ): Promise< + | { + supContextData: CodeWhispererSupplementalContext + items: CodeWhispererTokenClient.SupplementalContextList + } + | undefined + > + constructor(codeWhispererRegion: string, codeWhispererEndpoint: string) { this.codeWhispererRegion = codeWhispererRegion this.codeWhispererEndpoint = codeWhispererEndpoint @@ -200,6 +223,25 @@ export class CodeWhispererServiceIAM extends CodeWhispererServiceBase { return 'iam' } + async constructSupplementalContext( + document: TextDocument, + position: Position, + workspace: Workspace, + recentEditTracker: RecentEditTracker, + logging: Logging, + cancellationToken: CancellationToken, + opentabs: InlineCompletionWithReferencesParams['openTabFilepaths'], + config: { includeRecentEdits: boolean } + ): Promise< + | { + supContextData: CodeWhispererSupplementalContext + items: CodeWhispererTokenClient.SupplementalContextList + } + | undefined + > { + return undefined + } + async generateSuggestions(request: GenerateSuggestionsRequest): Promise { // add cancellation check // add error check @@ -296,6 +338,81 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { return 'bearer' } + async constructSupplementalContext( + document: TextDocument, + position: Position, + workspace: Workspace, + recentEditTracker: RecentEditTracker, + logging: Logging, + cancellationToken: CancellationToken, + opentabs: InlineCompletionWithReferencesParams['openTabFilepaths'], + config: { includeRecentEdits: boolean } + ): Promise< + | { + supContextData: CodeWhispererSupplementalContext + items: CodeWhispererTokenClient.SupplementalContextList + } + | undefined + > { + const items: CodeWhispererTokenClient.SupplementalContext[] = [] + + const projectContext = await fetchSupplementalContext( + document, + position, + workspace, + logging, + cancellationToken, + opentabs + ) + if (projectContext) { + items.push( + ...projectContext.supplementalContextItems.map(v => ({ + content: v.content, + filePath: v.filePath, + })) + ) + } + + const recentEditsContext = config.includeRecentEdits + ? await recentEditTracker.generateEditBasedContext(document) + : undefined + if (recentEditsContext) { + items.push( + ...recentEditsContext.supplementalContextItems.map(item => ({ + content: item.content, + filePath: item.filePath, + type: 'PreviousEditorState', + metadata: { + previousEditorStateMetadata: { + timeOffset: 1000, + }, + }, + })) + ) + } + + const merged: CodeWhispererSupplementalContext | undefined = recentEditsContext + ? { + contentsLength: (projectContext?.contentsLength || 0) + (recentEditsContext?.contentsLength || 0), + latency: Math.max(projectContext?.latency || 0, recentEditsContext?.latency || 0), + isUtg: projectContext?.isUtg || false, + isProcessTimeout: projectContext?.isProcessTimeout || false, + strategy: recentEditsContext ? 'recentEdits' : projectContext?.strategy || 'Empty', + supplementalContextItems: [ + ...(projectContext?.supplementalContextItems || []), + ...(recentEditsContext?.supplementalContextItems || []), + ], + } + : projectContext + + return merged + ? { + supContextData: merged, + items: items, + } + : undefined + } + private withProfileArn(request: T): T { if (!this.profileArn) return request @@ -305,28 +422,60 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { async generateSuggestions(request: GenerateSuggestionsRequest): Promise { // add cancellation check // add error check - if (this.customizationArn) request.customizationArn = this.customizationArn - const response = await this.client.generateCompletions(this.withProfileArn(request)).promise() - this.logging.info( - `GenerateCompletion response: + let logstr = `GenerateCompletion activity:\n` + try { + if (this.customizationArn) request.customizationArn = this.customizationArn + const beforeApiCall = performance.now() + let recentEditsLogStr = '' + const recentEdits = request.supplementalContexts?.filter(it => it.type === 'PreviousEditorState') + if (recentEdits) { + if (recentEdits.length === 0) { + recentEditsLogStr += `No recent edits` + } else { + recentEditsLogStr += '\n' + for (let i = 0; i < recentEdits.length; i++) { + const e = recentEdits[i] + recentEditsLogStr += `[recentEdits ${i}th]:\n` + recentEditsLogStr += `${e.content}\n` + } + } + } + logstr += `@@request metadata@@ "endpoint": ${this.codeWhispererEndpoint}, - "requestId": ${response.$response.requestId}, - "responseCompletionCount": ${response.completions?.length ?? 0}, - "responsePredictionCount": ${response.predictions?.length ?? 0}, - "suggestionType": ${request.predictionTypes?.toString() ?? ''}, + "predictionType": ${request.predictionTypes?.toString() ?? 'Not specified (COMPLETIONS)'}, "filename": ${request.fileContext.filename}, "language": ${request.fileContext.programmingLanguage.languageName}, - "supplementalContextLength": ${request.supplementalContexts?.length ?? 0}, + "supplementalContextCount": ${request.supplementalContexts?.length ?? 0}, "request.nextToken": ${request.nextToken}, - "response.nextToken": ${response.nextToken}` - ) + "recentEdits": ${recentEditsLogStr}\n` - const responseContext = { - requestId: response?.$response?.requestId, - codewhispererSessionId: response?.$response?.httpResponse?.headers['x-amzn-sessionid'], - nextToken: response.nextToken, + const response = await this.client.generateCompletions(this.withProfileArn(request)).promise() + + const responseContext = { + requestId: response?.$response?.requestId, + codewhispererSessionId: response?.$response?.httpResponse?.headers['x-amzn-sessionid'], + nextToken: response.nextToken, + } + + const r = this.mapCodeWhispererApiResponseToSuggestion(response, responseContext) + const firstSuggestionLogstr = r.suggestions.length > 0 ? `\n${r.suggestions[0].content}` : 'No suggestion' + + logstr += `@@response metadata@@ + "requestId": ${responseContext.requestId}, + "sessionId": ${responseContext.codewhispererSessionId}, + "response.completions.length": ${response.completions?.length ?? 0}, + "response.predictions.length": ${response.predictions?.length ?? 0}, + "latency": ${performance.now() - beforeApiCall}, + "response.nextToken": ${response.nextToken}, + "firstSuggestion": ${firstSuggestionLogstr}` + + return r + } catch (e) { + logstr += `error: ${(e as Error).message}` + throw e + } finally { + this.logging.info(logstr) } - return this.mapCodeWhispererApiResponseToSuggestion(response, responseContext) } private mapCodeWhispererApiResponseToSuggestion( @@ -436,6 +585,13 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { return this.client.getCodeAnalysis(this.withProfileArn(request)).promise() } + /** + * @description Get profile details + */ + async getProfile(request: { profileArn: string }) { + return this.client.getProfile(request).promise() + } + /** * @description Once scan completed successfully, send a request to get list of all the findings for the given scan. */ 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 14df60adb9..04d1cf091c 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts @@ -207,7 +207,7 @@ describe('TelemetryService', () => { telemetryService = new TelemetryService(serviceManagerStub, mockCredentialsProvider, telemetry, logging) - telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession) + telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession, 'Accept') sinon.assert.notCalled(codeWhisperServiceStub.sendTelemetryEvent) }) @@ -222,7 +222,7 @@ describe('TelemetryService', () => { telemetryService.updateOptOutPreference('OPTOUT') - telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession) + telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession, 'Accept') sinon.assert.notCalled(codeWhisperServiceStub.sendTelemetryEvent) }) @@ -238,7 +238,7 @@ describe('TelemetryService', () => { }) // Emitting event with IdC connection - telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession) + telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession, 'Accept') sinon.assert.calledOnce(codeWhisperServiceStub.sendTelemetryEvent) @@ -251,7 +251,7 @@ describe('TelemetryService', () => { codeWhisperServiceStub.sendTelemetryEvent.resetHistory() // Should not emit event anymore with BuilderId - telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession) + telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession, 'Accept') sinon.assert.notCalled(codeWhisperServiceStub.sendTelemetryEvent) }) @@ -290,7 +290,7 @@ describe('TelemetryService', () => { telemetryService.updateEnableTelemetryEventsToDestination(true) telemetryService.updateOptOutPreference('OPTIN') - telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession) + telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession, 'Accept') sinon.assert.calledOnceWithExactly(codeWhisperServiceStub.sendTelemetryEvent, expectedUserTriggerDecisionEvent) sinon.assert.calledOnceWithExactly(telemetry.emitMetric as sinon.SinonStub, { @@ -336,7 +336,7 @@ describe('TelemetryService', () => { telemetryService = new TelemetryService(serviceManagerStub, mockCredentialsProvider, telemetry, logging) telemetryService.updateEnableTelemetryEventsToDestination(false) telemetryService.updateOptOutPreference('OPTOUT') - telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession) + telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession, 'Accept') sinon.assert.neverCalledWithMatch(telemetry.emitMetric as sinon.SinonStub, { name: 'codewhisperer_userTriggerDecision', }) diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts index bdb88ef756..6f3ba52028 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts @@ -5,7 +5,10 @@ import { Logging, Telemetry, } from '@aws/language-server-runtimes/server-interface' -import { CodeWhispererSession } from '../../language-server/inline-completion/session/sessionManager' +import { + CodeWhispererSession, + UserTriggerDecision, +} from '../../language-server/inline-completion/session/sessionManager' import { SuggestionState, UserTriggerDecisionEvent, @@ -115,14 +118,10 @@ export class TelemetryService { return service } - private getSuggestionState(session: CodeWhispererSession): SuggestionState { + private getSuggestionState(userTriggerDecision: UserTriggerDecision): SuggestionState { let suggestionState: SuggestionState // Edits show one suggestion sequentially (with pagination), so use latest itemId state; // Completions show multiple suggestions together, so aggregate all states - const userTriggerDecision = - session.suggestionType === SuggestionType.EDIT - ? session.getLatestUserTriggerDecision() - : session.getAggregatedUserTriggerDecision() switch (userTriggerDecision) { case 'Accept': suggestionState = 'ACCEPT' @@ -192,6 +191,7 @@ export class TelemetryService { public emitUserTriggerDecision( session: CodeWhispererSession, + userTriggerDecision: UserTriggerDecision, timeSinceLastUserModification?: number, addedCharacterCount?: number, deletedCharacterCount?: number, @@ -265,7 +265,7 @@ export class TelemetryService { }, completionType: session.suggestions.length > 0 ? getCompletionType(session.suggestions[0]).toUpperCase() : 'LINE', - suggestionState: this.getSuggestionState(session), + suggestionState: this.getSuggestionState(userTriggerDecision), recommendationLatencyMilliseconds: session.firstCompletionDisplayLatency ? session.firstCompletionDisplayLatency : 0, @@ -287,7 +287,8 @@ export class TelemetryService { "acceptedCharacterCount": ${event.acceptedCharacterCount} "addedCharacterCount": ${event.addedCharacterCount} "deletedCharacterCount": ${event.deletedCharacterCount} - "streakLength": ${event.streakLength}`) + "streakLength": ${event.streakLength} + "firstCompletionDisplayLatency: ${event.recommendationLatencyMilliseconds}`) return this.invokeSendTelemetryEvent({ userTriggerDecisionEvent: event, }) diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.test.ts b/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.test.ts index 565a9505a4..0cfa7fd768 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert' import * as sinon from 'sinon' -import { InitializeParams, Platform } from '@aws/language-server-runtimes/server-interface' +import { InitializeParams, Platform, ServerInfo } from '@aws/language-server-runtimes/server-interface' import { getUserAgent, makeUserContextObject } from './telemetryUtils' describe('getUserAgent', () => { @@ -115,6 +115,7 @@ describe('getUserAgent', () => { describe('makeUserContextObject', () => { let mockInitializeParams: InitializeParams + let mockServerInfo: ServerInfo // let osStub: sinon.SinonStubbedInstance<{ now: () => number }> beforeEach(() => { @@ -123,10 +124,10 @@ describe('makeUserContextObject', () => { aws: { clientInfo: { name: 'test-custom-client-name', - version: '1.2.3', + version: '1.0.0', extension: { name: 'AmazonQ-For-VSCode', - version: '2.2.2', + version: '2.0.0', }, clientId: 'test-client-id', }, @@ -138,6 +139,11 @@ describe('makeUserContextObject', () => { }, } as InitializeParams + mockServerInfo = { + name: 'foo', + version: '3.0.0', + } + sinon.stub(process, 'platform').value('win32') }) @@ -146,33 +152,33 @@ describe('makeUserContextObject', () => { }) it('should return a valid UserContext object', () => { - const result = makeUserContextObject(mockInitializeParams, 'win32', 'TestProduct') + const result = makeUserContextObject(mockInitializeParams, 'win32', 'TestProduct', mockServerInfo) assert(result) assert.ok('ideCategory' in result) assert.ok('operatingSystem' in result) assert.strictEqual(result.operatingSystem, 'WINDOWS') assert.strictEqual(result.product, 'TestProduct') assert.strictEqual(result.clientId, 'test-client-id') - assert.strictEqual(result.ideVersion, '1.2.3') + assert.strictEqual(result.ideVersion, 'ide=1.0.0;plugin=2.0.0;lsp=3.0.0') }) it('should prefer initializationOptions.aws version over clientInfo version', () => { - const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct') - assert.strictEqual(result?.ideVersion, '1.2.3') + const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct', mockServerInfo) + assert.strictEqual(result?.ideVersion, 'ide=1.0.0;plugin=2.0.0;lsp=3.0.0') }) it('should use clientInfo version if initializationOptions.aws version is not available', () => { // @ts-ignore mockInitializeParams.initializationOptions.aws.clientInfo.version = undefined - const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct') - assert.strictEqual(result?.ideVersion, '1.1.1') + const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct', mockServerInfo) + assert.strictEqual(result?.ideVersion, 'ide=1.1.1;plugin=2.0.0;lsp=3.0.0') }) it('should return undefined if ideCategory is not in IDE_CATEGORY_MAP', () => { // @ts-ignore mockInitializeParams.initializationOptions.aws.clientInfo.extension.name = 'Unknown IDE' - const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct') + const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct', mockServerInfo) assert.strictEqual(result, undefined) }) @@ -188,7 +194,7 @@ describe('makeUserContextObject', () => { // @ts-ignore mockInitializeParams.initializationOptions.aws.clientInfo.extension.name = clientName - const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct') + const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct', mockServerInfo) switch (clientName) { case 'AmazonQ-For-VSCode': assert.strictEqual(result?.ideCategory, 'VSCODE') @@ -222,7 +228,7 @@ describe('makeUserContextObject', () => { ] platforms.forEach(platform => { - const result = makeUserContextObject(mockInitializeParams, platform, 'TestProduct') + const result = makeUserContextObject(mockInitializeParams, platform, 'TestProduct', mockServerInfo) switch (platform) { case 'win32': assert.strictEqual(result?.operatingSystem, 'WINDOWS') diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.ts b/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.ts index 73e94d4233..49020a8822 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.ts @@ -89,15 +89,20 @@ const getOperatingSystem = (platform: Platform) => { export const makeUserContextObject = ( initializeParams: InitializeParams, platform: Platform, - product: string + product: string, + serverInfo: ServerInfo ): UserContext | undefined => { + const ide = getIdeCategory(initializeParams) + const ideVersion = + initializeParams.initializationOptions?.aws?.clientInfo?.version || initializeParams.clientInfo?.version + const pluginVersion = initializeParams.initializationOptions?.aws?.clientInfo?.extension?.version || '' + const lspVersion = serverInfo.version ?? '' const userContext: UserContext = { - ideCategory: getIdeCategory(initializeParams), + ideCategory: ide, operatingSystem: getOperatingSystem(platform), product: product, clientId: initializeParams.initializationOptions?.aws?.clientInfo?.clientId, - ideVersion: - initializeParams.initializationOptions?.aws?.clientInfo?.version || initializeParams.clientInfo?.version, + ideVersion: `ide=${ideVersion};plugin=${pluginVersion};lsp=${lspVersion}`, } if (userContext.ideCategory === 'UNKNOWN' || userContext.operatingSystem === 'UNKNOWN') { diff --git a/server/aws-lsp-codewhisperer/src/shared/utils.test.ts b/server/aws-lsp-codewhisperer/src/shared/utils.test.ts index e83d04bbb3..85cd8817a7 100644 --- a/server/aws-lsp-codewhisperer/src/shared/utils.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/utils.test.ts @@ -134,26 +134,36 @@ describe('getClientName', () => { }) describe('getOriginFromClientInfo', () => { - it('returns MD_IDE for SMUS-IDE client name', () => { + it('returns MD_IDE for client names starting with SMUS-IDE prefix', () => { const result = getOriginFromClientInfo('AmazonQ-For-SMUS-IDE-1.0.0') assert.strictEqual(result, 'MD_IDE') }) - it('returns MD_IDE for SMUS-CE client name', () => { + it('returns MD_IDE for client names starting with SMUS-CE prefix', () => { const result = getOriginFromClientInfo('AmazonQ-For-SMUS-CE-1.0.0') assert.strictEqual(result, 'MD_IDE') }) - it('returns MD_IDE for client names starting with SMUS-IDE prefix', () => { + it('returns MD_IDE for client names starting with SMAI-CE prefix', () => { + const result = getOriginFromClientInfo('AmazonQ-For-SMAI-CE-1.0.0') + assert.strictEqual(result, 'MD_IDE') + }) + + it('returns MD_IDE for SMUS-IDE client name', () => { const result = getOriginFromClientInfo('AmazonQ-For-SMUS-IDE') assert.strictEqual(result, 'MD_IDE') }) - it('returns MD_IDE for client names starting with SMUS-CE prefix', () => { + it('returns MD_IDE for SMUS-CE client name', () => { const result = getOriginFromClientInfo('AmazonQ-For-SMUS-CE') assert.strictEqual(result, 'MD_IDE') }) + it('returns MD_IDE for SMAI-CE client name', () => { + const result = getOriginFromClientInfo('AmazonQ-For-SMAI-CE') + assert.strictEqual(result, 'MD_IDE') + }) + it('returns IDE for non-SMUS client name', () => { const result = getOriginFromClientInfo('VSCode-Extension') assert.strictEqual(result, 'IDE') diff --git a/server/aws-lsp-codewhisperer/src/shared/utils.ts b/server/aws-lsp-codewhisperer/src/shared/utils.ts index e178de4a51..a7a95d8801 100644 --- a/server/aws-lsp-codewhisperer/src/shared/utils.ts +++ b/server/aws-lsp-codewhisperer/src/shared/utils.ts @@ -381,7 +381,12 @@ export function getClientName(lspParams: InitializeParams | undefined): string | } export function getOriginFromClientInfo(clientName: string | undefined): Origin { - if (clientName?.startsWith('AmazonQ-For-SMUS-IDE') || clientName?.startsWith('AmazonQ-For-SMUS-CE')) { + // TODO: Update with a new origin for SMAI case, as a short-term solution Sagemaker AI CE is using same origin as that of Sagemaker Unified Studio's IDE and CE + if ( + clientName?.startsWith('AmazonQ-For-SMUS-IDE') || + clientName?.startsWith('AmazonQ-For-SMUS-CE') || + clientName?.startsWith('AmazonQ-For-SMAI-CE') + ) { return 'MD_IDE' } return 'IDE' diff --git a/server/aws-lsp-identity/package.json b/server/aws-lsp-identity/package.json index 929bee5583..9aa4bedcbf 100644 --- a/server/aws-lsp-identity/package.json +++ b/server/aws-lsp-identity/package.json @@ -26,7 +26,7 @@ "dependencies": { "@aws-sdk/client-sso-oidc": "^3.616.0", "@aws-sdk/token-providers": "^3.744.0", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "^0.0.12", "@smithy/node-http-handler": "^3.2.5", "@smithy/shared-ini-file-loader": "^4.0.1", diff --git a/server/aws-lsp-json/CHANGELOG.md b/server/aws-lsp-json/CHANGELOG.md index 31c977b978..ecac136295 100644 --- a/server/aws-lsp-json/CHANGELOG.md +++ b/server/aws-lsp-json/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.1.18](https://github.com/aws/language-servers/compare/lsp-json/v0.1.17...lsp-json/v0.1.18) (2025-08-19) + + +### Bug Fixes + +* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @aws/lsp-core bumped from ^0.0.13 to ^0.0.14 + ## [0.1.17](https://github.com/aws/language-servers/compare/lsp-json/v0.1.16...lsp-json/v0.1.17) (2025-08-04) diff --git a/server/aws-lsp-json/package.json b/server/aws-lsp-json/package.json index df4d04e39a..ee5fbbc47a 100644 --- a/server/aws-lsp-json/package.json +++ b/server/aws-lsp-json/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-json", - "version": "0.1.17", + "version": "0.1.18", "description": "JSON Language Server", "main": "out/index.js", "repository": { @@ -26,8 +26,8 @@ "prepack": "shx cp ../../LICENSE ../../NOTICE ../../SECURITY.md ." }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13", + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8" }, diff --git a/server/aws-lsp-notification/package.json b/server/aws-lsp-notification/package.json index 6cc3037232..f19ddc54ca 100644 --- a/server/aws-lsp-notification/package.json +++ b/server/aws-lsp-notification/package.json @@ -22,7 +22,7 @@ "coverage:report": "c8 report --reporter=html --reporter=text" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "^0.0.12", "vscode-languageserver": "^9.0.1" }, diff --git a/server/aws-lsp-partiql/CHANGELOG.md b/server/aws-lsp-partiql/CHANGELOG.md index b1be4c4050..dc68bdb063 100644 --- a/server/aws-lsp-partiql/CHANGELOG.md +++ b/server/aws-lsp-partiql/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.0.17](https://github.com/aws/language-servers/compare/lsp-partiql/v0.0.16...lsp-partiql/v0.0.17) (2025-08-19) + + +### Bug Fixes + +* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289)) + ## [0.0.16](https://github.com/aws/language-servers/compare/lsp-partiql/v0.0.15...lsp-partiql/v0.0.16) (2025-08-04) diff --git a/server/aws-lsp-partiql/package.json b/server/aws-lsp-partiql/package.json index 620b3ba16b..dc2fc2ee22 100644 --- a/server/aws-lsp-partiql/package.json +++ b/server/aws-lsp-partiql/package.json @@ -3,7 +3,7 @@ "author": "Amazon Web Services", "license": "Apache-2.0", "description": "PartiQL language server", - "version": "0.0.16", + "version": "0.0.17", "repository": { "type": "git", "url": "https://github.com/aws/language-servers" @@ -24,7 +24,7 @@ "out" ], "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "antlr4-c3": "3.4.2", "antlr4ng": "3.0.14", "web-tree-sitter": "0.22.6" diff --git a/server/aws-lsp-s3/package.json b/server/aws-lsp-s3/package.json index e971b6af08..914628d14e 100644 --- a/server/aws-lsp-s3/package.json +++ b/server/aws-lsp-s3/package.json @@ -9,7 +9,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.623.0", "@aws-sdk/types": "^3.734.0", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "^0.0.12", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8" diff --git a/server/aws-lsp-yaml/CHANGELOG.md b/server/aws-lsp-yaml/CHANGELOG.md index 965da41cfc..5858e6eb66 100644 --- a/server/aws-lsp-yaml/CHANGELOG.md +++ b/server/aws-lsp-yaml/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.1.18](https://github.com/aws/language-servers/compare/lsp-yaml/v0.1.17...lsp-yaml/v0.1.18) (2025-08-19) + + +### Bug Fixes + +* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @aws/lsp-core bumped from ^0.0.13 to ^0.0.14 + ## [0.1.17](https://github.com/aws/language-servers/compare/lsp-yaml/v0.1.16...lsp-yaml/v0.1.17) (2025-08-04) diff --git a/server/aws-lsp-yaml/package.json b/server/aws-lsp-yaml/package.json index 2231bd884a..fe115132c8 100644 --- a/server/aws-lsp-yaml/package.json +++ b/server/aws-lsp-yaml/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-yaml", - "version": "0.1.17", + "version": "0.1.18", "description": "YAML Language Server", "main": "out/index.js", "repository": { @@ -26,8 +26,8 @@ "postinstall": "node patchYamlPackage.js" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13", + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8", "yaml-language-server": "1.13.0" diff --git a/server/device-sso-auth-lsp/package.json b/server/device-sso-auth-lsp/package.json index 529b59ad6b..e5085aa6bd 100644 --- a/server/device-sso-auth-lsp/package.json +++ b/server/device-sso-auth-lsp/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "vscode-languageserver": "^9.0.1" }, "devDependencies": { diff --git a/server/hello-world-lsp/package.json b/server/hello-world-lsp/package.json index e24f1d0ff7..8ab207f8b4 100644 --- a/server/hello-world-lsp/package.json +++ b/server/hello-world-lsp/package.json @@ -13,7 +13,7 @@ "coverage:report": "c8 report --reporter=html --reporter=text" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "vscode-languageserver": "^9.0.1" }, "devDependencies": {

|+nUA!_dr|X07_pPrwuYJcWw$h}K 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 literal 0 HcmV?d00001 diff --git a/app/aws-lsp-codewhisperer-runtimes/package.json b/app/aws-lsp-codewhisperer-runtimes/package.json index 6a1721382c..b890c01a78 100644 --- a/app/aws-lsp-codewhisperer-runtimes/package.json +++ b/app/aws-lsp-codewhisperer-runtimes/package.json @@ -10,10 +10,11 @@ "package:prod": "npm run compile && cross-env NODE_OPTIONS=--max_old_space_size=8172 npm run webpack:prod", "webpack": "webpack", "webpack:prod": "webpack --config webpack.config.prod.js", + "copy:native-deps:agent-standalone": "copyfiles -f _bundle-assets/registry-js/win32-x64/registry.node build/private/bundle/agent-standalone", "copy:resources:agent-standalone": "copyfiles -f --error ../../node_modules/@aws/lsp-identity/src/sso/authorizationCodePkce/resources/**/* build/private/bundle/agent-standalone/resources", "generate:node-assets": "./scripts/download-node.sh && ts-node src/scripts/copy-node-assets.ts", "generate:build-archive": "./scripts/package.sh", - "ci:generate:agent-standalone": "npm run package:prod && npm run copy:resources:agent-standalone && npm run generate:node-assets && npm run generate:build-archive", + "ci:generate:agent-standalone": "npm run package:prod && npm run copy:native-deps:agent-standalone && npm run copy:resources:agent-standalone && npm run generate:node-assets && npm run generate:build-archive", "ci:generate:manifest": "ts-node scripts/create-repo-manifest.ts", "start": "cross-env NODE_OPTIONS=--max_old_space_size=8172 node scripts/dev-server.js start", "stop-dev-server": "node scripts/dev-server.js stop", @@ -22,7 +23,7 @@ "local-build": "node scripts/local-build.js" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-codewhisperer": "*", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", diff --git a/app/aws-lsp-codewhisperer-runtimes/src/version.json b/app/aws-lsp-codewhisperer-runtimes/src/version.json index 3f5ef793d0..93401eb637 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.25.0" + "agenticChat": "1.29.0" } diff --git a/app/aws-lsp-identity-runtimes/package.json b/app/aws-lsp-identity-runtimes/package.json index b4971eae87..f33fa80da4 100644 --- a/app/aws-lsp-identity-runtimes/package.json +++ b/app/aws-lsp-identity-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-identity": "^0.0.1" } } diff --git a/app/aws-lsp-json-runtimes/package.json b/app/aws-lsp-json-runtimes/package.json index 9fa7b7e958..db1949f54b 100644 --- a/app/aws-lsp-json-runtimes/package.json +++ b/app/aws-lsp-json-runtimes/package.json @@ -11,7 +11,7 @@ "webpack": "webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-json": "*" }, "devDependencies": { diff --git a/app/aws-lsp-notification-runtimes/package.json b/app/aws-lsp-notification-runtimes/package.json index a355a967bf..a5eeecae49 100644 --- a/app/aws-lsp-notification-runtimes/package.json +++ b/app/aws-lsp-notification-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-notification": "^0.0.1" } } diff --git a/app/aws-lsp-partiql-runtimes/package.json b/app/aws-lsp-partiql-runtimes/package.json index 0d5e07cddf..d483f3d0ce 100644 --- a/app/aws-lsp-partiql-runtimes/package.json +++ b/app/aws-lsp-partiql-runtimes/package.json @@ -11,7 +11,7 @@ "package": "npm run compile && npm run compile:webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.120", + "@aws/language-server-runtimes": "^0.2.125", "@aws/lsp-partiql": "^0.0.5" }, "devDependencies": { diff --git a/app/aws-lsp-s3-runtimes/package.json b/app/aws-lsp-s3-runtimes/package.json index 1c15a9af5e..9feb4f7ddc 100644 --- a/app/aws-lsp-s3-runtimes/package.json +++ b/app/aws-lsp-s3-runtimes/package.json @@ -10,7 +10,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-s3": "^0.0.1" } } diff --git a/app/aws-lsp-yaml-json-webworker/package.json b/app/aws-lsp-yaml-json-webworker/package.json index bfb5fa6277..fc524cabdc 100644 --- a/app/aws-lsp-yaml-json-webworker/package.json +++ b/app/aws-lsp-yaml-json-webworker/package.json @@ -11,7 +11,7 @@ "serve:webpack": "NODE_ENV=development webpack serve" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*" }, diff --git a/app/aws-lsp-yaml-runtimes/package.json b/app/aws-lsp-yaml-runtimes/package.json index 86638b8ffc..09bb93ee9a 100644 --- a/app/aws-lsp-yaml-runtimes/package.json +++ b/app/aws-lsp-yaml-runtimes/package.json @@ -11,7 +11,7 @@ "webpack": "webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-yaml": "*" }, "devDependencies": { diff --git a/app/hello-world-lsp-runtimes/package.json b/app/hello-world-lsp-runtimes/package.json index 2ccccc694b..bff976d9b3 100644 --- a/app/hello-world-lsp-runtimes/package.json +++ b/app/hello-world-lsp-runtimes/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@aws/hello-world-lsp": "^0.0.1", - "@aws/language-server-runtimes": "^0.2.121" + "@aws/language-server-runtimes": "^0.2.127" }, "devDependencies": { "@types/chai": "^4.3.5", diff --git a/chat-client/CHANGELOG.md b/chat-client/CHANGELOG.md index 41178f1cb7..995ddc8ae6 100644 --- a/chat-client/CHANGELOG.md +++ b/chat-client/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## [0.1.33](https://github.com/aws/language-servers/compare/chat-client/v0.1.32...chat-client/v0.1.33) (2025-08-19) + + +### Features + +* **amazonq:** added mcp admin level configuration with GetProfile ([#2000](https://github.com/aws/language-servers/issues/2000)) ([fd6e9a8](https://github.com/aws/language-servers/commit/fd6e9a829c6229c276de5340dffce52b426a864d)) +* **amazonq:** read tool ui revamp ([#2113](https://github.com/aws/language-servers/issues/2113)) ([#2121](https://github.com/aws/language-servers/issues/2121)) ([93cf229](https://github.com/aws/language-servers/commit/93cf229149ba60491f9f5763793db4a9f570b611)) + + +### Bug Fixes + +* fix for button text and remove profilearn caching ([#2137](https://github.com/aws/language-servers/issues/2137)) ([2a4171a](https://github.com/aws/language-servers/commit/2a4171a74c15c23c23c481060496162bcc9e6284)) +* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289)) + +## [0.1.32](https://github.com/aws/language-servers/compare/chat-client/v0.1.31...chat-client/v0.1.32) (2025-08-11) + + +### Features + +* **amazonq:** read tool ui revamp ([c65428b](https://github.com/aws/language-servers/commit/c65428bab2cf5e47badf1e3a9453babcf881e60c)) + +## [0.1.31](https://github.com/aws/language-servers/compare/chat-client/v0.1.30...chat-client/v0.1.31) (2025-08-06) + + +### Features + +* **amazonq:** add two more tips for the did you know section ([#2063](https://github.com/aws/language-servers/issues/2063)) ([9949c6d](https://github.com/aws/language-servers/commit/9949c6dd81c56b5ea82563310da2eaee4d00a059)) +* **amazonq:** enable sonnet 4 for fra region ([#2069](https://github.com/aws/language-servers/issues/2069)) ([3a4b8df](https://github.com/aws/language-servers/commit/3a4b8df981b2c3b0532360a11472169fffec7924)) + + +### Bug Fixes + +* **amazonq:** fix to add disable/enable feature back to mcp servers ([#2052](https://github.com/aws/language-servers/issues/2052)) ([c03e017](https://github.com/aws/language-servers/commit/c03e017b9ccbbbb9c80a3c3afd5da38a50bd6cff)) + ## [0.1.30](https://github.com/aws/language-servers/compare/chat-client/v0.1.29...chat-client/v0.1.30) (2025-08-04) diff --git a/chat-client/package.json b/chat-client/package.json index 9eb33a6a11..e4250d42a1 100644 --- a/chat-client/package.json +++ b/chat-client/package.json @@ -1,6 +1,6 @@ { "name": "@aws/chat-client", - "version": "0.1.30", + "version": "0.1.33", "description": "AWS Chat Client", "main": "out/index.js", "repository": { @@ -25,9 +25,9 @@ }, "dependencies": { "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/language-server-runtimes-types": "^0.1.50", - "@aws/mynah-ui": "^4.36.2" + "@aws/mynah-ui": "^4.36.5" }, "devDependencies": { "@types/jsdom": "^21.1.6", diff --git a/chat-client/src/client/mcpMynahUi.test.ts b/chat-client/src/client/mcpMynahUi.test.ts index 3e1ca73f33..947e5bc604 100644 --- a/chat-client/src/client/mcpMynahUi.test.ts +++ b/chat-client/src/client/mcpMynahUi.test.ts @@ -107,10 +107,8 @@ describe('McpMynahUi', () => { assert.strictEqual(callArgs.detailedList.header.description, 'Test Description') assert.deepStrictEqual(callArgs.detailedList.header.status, { status: 'success' }) - // Verify the actions in the header - assert.strictEqual(callArgs.detailedList.header.actions.length, 2) - assert.strictEqual(callArgs.detailedList.header.actions[0].id, 'add-new-mcp') - assert.strictEqual(callArgs.detailedList.header.actions[1].id, 'refresh-mcp-list') + // Verify the actions in the header (no default actions are added when header is provided) + assert.strictEqual(callArgs.detailedList.header.actions.length, 0) // Verify the list structure assert.strictEqual(callArgs.detailedList.list.length, 1) @@ -577,8 +575,9 @@ describe('McpMynahUi', () => { assert.strictEqual(detailedList.header.actions[0].id, 'mcp-details-menu') // Verify the mcp-details-menu items - assert.strictEqual(detailedList.header.actions[0].items.length, 1) - assert.strictEqual(detailedList.header.actions[0].items[0].id, 'mcp-delete-server') + assert.strictEqual(detailedList.header.actions[0].items.length, 2) + assert.strictEqual(detailedList.header.actions[0].items[0].id, 'mcp-disable-server') + assert.strictEqual(detailedList.header.actions[0].items[1].id, 'mcp-delete-server') assert.strictEqual(detailedList.filterOptions.length, 1) assert.strictEqual(detailedList.filterOptions[0].id, 'permission') diff --git a/chat-client/src/client/mcpMynahUi.ts b/chat-client/src/client/mcpMynahUi.ts index 3e64336876..5cdae85da1 100644 --- a/chat-client/src/client/mcpMynahUi.ts +++ b/chat-client/src/client/mcpMynahUi.ts @@ -167,11 +167,11 @@ export class McpMynahUi { id: MCP_IDS.DETAILS_MENU, icon: toMynahIcon('ellipsis'), items: [ - // { - // id: MCP_IDS.DISABLE_SERVER, - // text: `Disable MCP server`, - // data: { serverName }, - // }, + { + id: MCP_IDS.DISABLE_SERVER, + text: `Disable MCP server`, + data: { serverName }, + }, { id: MCP_IDS.DELETE_SERVER, confirmation: { @@ -220,10 +220,10 @@ export class McpMynahUi { ...(action.id === MCP_IDS.DETAILS_MENU ? { items: [ - // { - // id: MCP_IDS.DISABLE_SERVER, - // text: `Disable MCP server`, - // }, + { + id: MCP_IDS.DISABLE_SERVER, + text: `Disable MCP server`, + }, { id: MCP_IDS.DELETE_SERVER, confirmation: { @@ -272,20 +272,12 @@ export class McpMynahUi { title: params.header.title, description: params.header.description, status: params.header.status, - actions: [ - { - id: MCP_IDS.ADD_NEW, - icon: toMynahIcon('plus'), - status: 'clear', - description: 'Add new MCP', - }, - { - id: MCP_IDS.REFRESH_LIST, - icon: toMynahIcon('refresh'), - status: 'clear', - description: 'Refresh MCP servers', - }, - ], + actions: + params.header.actions?.map(action => ({ + ...action, + icon: action.icon ? toMynahIcon(action.icon) : undefined, + text: undefined, + })) || [], } : undefined, filterOptions: params.filterOptions?.map(filter => ({ diff --git a/chat-client/src/client/mynahUi.ts b/chat-client/src/client/mynahUi.ts index 525a9382e1..15cbeae7ff 100644 --- a/chat-client/src/client/mynahUi.ts +++ b/chat-client/src/client/mynahUi.ts @@ -842,6 +842,7 @@ export const createMynahUi = ( // if we want to max user input as 500000, need to configure the maxUserInput as 500096 maxUserInput: 500096, userInputLengthWarningThreshold: 450000, + disableTypewriterAnimation: true, }, } @@ -1368,10 +1369,15 @@ export const createMynahUi = ( fileTreeTitle: '', hideFileCount: true, details: toDetailsWithoutIcon(header.fileList.details), + renderAsPills: + !header.fileList.details || + (Object.values(header.fileList.details).every(detail => !detail.changes) && + (!header.buttons || !header.buttons.some(button => button.id === 'undo-changes')) && + !header.status?.icon), } } if (!isPartialResult) { - if (processedHeader) { + if (processedHeader && !message.header?.status) { processedHeader.status = undefined } } @@ -1384,7 +1390,8 @@ export const createMynahUi = ( processedHeader.buttons !== null && processedHeader.buttons.length > 0) || processedHeader.status !== undefined || - processedHeader.icon !== undefined) + processedHeader.icon !== undefined || + processedHeader.fileList !== undefined) const padding = message.type === 'tool' ? (fileList ? true : message.messageId?.endsWith('_permission')) : undefined @@ -1395,8 +1402,10 @@ export const createMynahUi = ( // Adding this conditional check to show the stop message in the center. const contentHorizontalAlignment: ChatItem['contentHorizontalAlignment'] = undefined - // If message.header?.status?.text is Stopped or Rejected or Ignored or Completed etc.. card should be in disabled state. - const shouldMute = message.header?.status?.text !== undefined && message.header?.status?.text !== 'Completed' + // If message.header?.status?.text is Stopped or Rejected or Ignored etc.. card should be in disabled state. + const shouldMute = + message.header?.status?.text !== undefined && + ['Stopped', 'Rejected', 'Ignored', 'Failed', 'Error'].includes(message.header.status.text) return { body: message.body, diff --git a/chat-client/src/client/tabs/tabFactory.ts b/chat-client/src/client/tabs/tabFactory.ts index c0ab3ebac9..6df349896c 100644 --- a/chat-client/src/client/tabs/tabFactory.ts +++ b/chat-client/src/client/tabs/tabFactory.ts @@ -184,6 +184,8 @@ Select code & ask me to explain, debug or optimize it, or type \`/\` for quick a 'MCP is available in Amazon Q!', 'Pinned context is always included in future chat messages', 'Create and add Saved Prompts using the @ context menu', + 'Compact your conversation with /compact', + 'Ask Q to review your code and see results in the code issues panel!', ] const randomIndex = Math.floor(Math.random() * hints.length) diff --git a/client/vscode/package.json b/client/vscode/package.json index 4b8c2fdb4c..a5d7f66d21 100644 --- a/client/vscode/package.json +++ b/client/vscode/package.json @@ -352,7 +352,7 @@ "@aws-sdk/credential-providers": "^3.731.1", "@aws-sdk/types": "^3.734.0", "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@types/uuid": "^9.0.8", "@types/vscode": "^1.98.0", "jose": "^5.2.4", diff --git a/core/aws-lsp-core/CHANGELOG.md b/core/aws-lsp-core/CHANGELOG.md index 9e2e616f55..0e1e63dd38 100644 --- a/core/aws-lsp-core/CHANGELOG.md +++ b/core/aws-lsp-core/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [0.0.14](https://github.com/aws/language-servers/compare/lsp-core/v0.0.13...lsp-core/v0.0.14) (2025-08-19) + + +### Features + +* **amazonq:** added mcp admin level configuration with GetProfile ([#2000](https://github.com/aws/language-servers/issues/2000)) ([fd6e9a8](https://github.com/aws/language-servers/commit/fd6e9a829c6229c276de5340dffce52b426a864d)) + + +### Bug Fixes + +* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289)) + ## [0.0.13](https://github.com/aws/language-servers/compare/lsp-core/v0.0.12...lsp-core/v0.0.13) (2025-08-04) diff --git a/core/aws-lsp-core/package.json b/core/aws-lsp-core/package.json index 4c033448eb..54ca980fc1 100644 --- a/core/aws-lsp-core/package.json +++ b/core/aws-lsp-core/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-core", - "version": "0.0.13", + "version": "0.0.14", "description": "Core library, contains common code and utilities", "main": "out/index.js", "repository": { @@ -28,7 +28,7 @@ "prepack": "shx cp ../../LICENSE ../../NOTICE ../../SECURITY.md ." }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@gerhobbelt/gitignore-parser": "^0.2.0-9", "cross-spawn": "7.0.6", "jose": "^5.2.4", diff --git a/core/aws-lsp-core/src/index.ts b/core/aws-lsp-core/src/index.ts index 1ad391dc8f..030a4fa2e3 100644 --- a/core/aws-lsp-core/src/index.ts +++ b/core/aws-lsp-core/src/index.ts @@ -19,3 +19,4 @@ export * as workspaceUtils from './util/workspaceUtils' export * as processUtils from './util/processUtils' export * as collectionUtils from './util/collectionUtils' export * as loggingUtils from './util/loggingUtils' +export * as retryUtils from './util/retryUtils' diff --git a/core/aws-lsp-core/src/util/retryUtils.test.ts b/core/aws-lsp-core/src/util/retryUtils.test.ts new file mode 100644 index 0000000000..4223e1f3bf --- /dev/null +++ b/core/aws-lsp-core/src/util/retryUtils.test.ts @@ -0,0 +1,120 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. + * All Rights Reserved. SPDX-License-Identifier: Apache-2.0 + */ + +import { expect } from 'chai' +import * as sinon from 'sinon' +import { retryWithBackoff, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY } from './retryUtils' + +describe('retryUtils', () => { + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + afterEach(() => { + clock.restore() + }) + + describe('retryWithBackoff', () => { + it('should return result on first success', async () => { + const fn = sinon.stub().resolves('success') + + const result = await retryWithBackoff(fn) + + expect(result).to.equal('success') + expect(fn.callCount).to.equal(1) + }) + + it('should retry on retryable errors', async () => { + const fn = sinon.stub() + fn.onFirstCall().rejects({ code: 'ThrottlingException' }) + fn.onSecondCall().resolves('success') + + const promise = retryWithBackoff(fn) + await clock.tickAsync(DEFAULT_BASE_DELAY) + const result = await promise + + expect(result).to.equal('success') + expect(fn.callCount).to.equal(2) + }) + + it('should not retry on non-retryable client errors', async () => { + const error = { statusCode: 404 } + const fn = sinon.stub().rejects(error) + + try { + await retryWithBackoff(fn) + expect.fail('Expected function to throw') + } catch (e) { + expect(e).to.equal(error) + } + expect(fn.callCount).to.equal(1) + }) + + it('should retry on server errors', async () => { + const fn = sinon.stub() + fn.onFirstCall().rejects({ statusCode: 500 }) + fn.onSecondCall().resolves('success') + + const promise = retryWithBackoff(fn) + await clock.tickAsync(DEFAULT_BASE_DELAY) + const result = await promise + + expect(result).to.equal('success') + expect(fn.callCount).to.equal(2) + }) + + it('should use exponential backoff by default', async () => { + const fn = sinon.stub() + const error = { code: 'ThrottlingException' } + fn.onFirstCall().rejects(error) + fn.onSecondCall().rejects(error) + + const promise = retryWithBackoff(fn) + + // First retry after baseDelay * 1 + await clock.tickAsync(DEFAULT_BASE_DELAY) + // Second retry after baseDelay * 2 + await clock.tickAsync(DEFAULT_BASE_DELAY * 2) + + try { + await promise + expect.fail('Expected function to throw') + } catch (e) { + expect(e).to.equal(error) + } + expect(fn.callCount).to.equal(DEFAULT_MAX_RETRIES) + }) + + it('should respect custom maxRetries', async () => { + const error = { code: 'ThrottlingException' } + const fn = sinon.stub().rejects(error) + + try { + await retryWithBackoff(fn, { maxRetries: 1 }) + expect.fail('Expected function to throw') + } catch (e) { + expect(e).to.equal(error) + } + expect(fn.callCount).to.equal(1) + }) + + it('should use custom isRetryable function', async () => { + const error = { custom: 'error' } + const fn = sinon.stub().rejects(error) + const isRetryable = sinon.stub().returns(false) + + try { + await retryWithBackoff(fn, { isRetryable }) + expect.fail('Expected function to throw') + } catch (e) { + expect(e).to.equal(error) + } + expect(fn.callCount).to.equal(1) + expect(isRetryable.calledWith(error)).to.equal(true) + }) + }) +}) diff --git a/core/aws-lsp-core/src/util/retryUtils.ts b/core/aws-lsp-core/src/util/retryUtils.ts new file mode 100644 index 0000000000..dc135ce23d --- /dev/null +++ b/core/aws-lsp-core/src/util/retryUtils.ts @@ -0,0 +1,77 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. + * All Rights Reserved. SPDX-License-Identifier: Apache-2.0 + */ + +// Default retry configuration constants +export const DEFAULT_MAX_RETRIES = 2 +export const DEFAULT_BASE_DELAY = 500 +export const DEFAULT_EXPONENTIAL_BACKOFF = true + +// HTTP status code constants +const CLIENT_ERROR_MIN = 400 +const CLIENT_ERROR_MAX = 500 +const INTERNAL_SERVER_ERROR = 500 +const SERVICE_UNAVAILABLE = 503 + +// AWS error code constants +const THROTTLING_EXCEPTION = 'ThrottlingException' +const INTERNAL_SERVER_EXCEPTION = 'InternalServerException' + +export interface RetryOptions { + /** Maximum number of retry attempts (default: DEFAULT_MAX_RETRIES) */ + maxRetries?: number + /** Base delay in milliseconds (default: DEFAULT_BASE_DELAY) */ + baseDelay?: number + /** Whether to use exponential backoff (default: DEFAULT_EXPONENTIAL_BACKOFF) */ + exponentialBackoff?: boolean + /** Custom function to determine if an error is retryable */ + isRetryable?: (error: any) => boolean +} + +/** + * Default AWS error retry logic + */ +function defaultIsRetryable(error: any): boolean { + const errorCode = error.code || error.name + const statusCode = error.statusCode + + // Fast fail on non-retryable client errors (except throttling) + if (statusCode >= CLIENT_ERROR_MIN && statusCode < CLIENT_ERROR_MAX && errorCode !== THROTTLING_EXCEPTION) { + return false + } + + // Retry on throttling, server errors, and specific status codes + return ( + errorCode === THROTTLING_EXCEPTION || + errorCode === INTERNAL_SERVER_EXCEPTION || + statusCode === INTERNAL_SERVER_ERROR || + statusCode === SERVICE_UNAVAILABLE + ) +} + +/** + * Executes a function with retry logic and exponential backoff + */ +export async function retryWithBackoff(fn: () => Promise, options: RetryOptions = {}): Promise { + const { + maxRetries = DEFAULT_MAX_RETRIES, + baseDelay = DEFAULT_BASE_DELAY, + exponentialBackoff = DEFAULT_EXPONENTIAL_BACKOFF, + isRetryable = defaultIsRetryable, + } = options + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn() + } catch (error: any) { + if (!isRetryable(error) || attempt === maxRetries - 1) { + throw error + } + + const delay = exponentialBackoff ? baseDelay * (attempt + 1) : baseDelay + await new Promise(resolve => setTimeout(resolve, delay)) + } + } + throw new Error('Retry failed') +} diff --git a/integration-tests/q-agentic-chat-server/package.json b/integration-tests/q-agentic-chat-server/package.json index 26202df0c1..ffb33f1a4e 100644 --- a/integration-tests/q-agentic-chat-server/package.json +++ b/integration-tests/q-agentic-chat-server/package.json @@ -9,7 +9,7 @@ "test-integ": "npm run compile && mocha --timeout 30000 \"./out/**/*.test.js\" --retries 2" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "*" }, "devDependencies": { diff --git a/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts b/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts index 897ee02071..c5a8046bd5 100644 --- a/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts +++ b/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts @@ -169,11 +169,11 @@ describe('Q Agentic Chat Server Integration Tests', async () => { expect(decryptedResult.additionalMessages).to.be.an('array') const fsReadMessage = decryptedResult.additionalMessages?.find( - msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 file read' + msg => msg.type === 'tool' && msg.header?.body === '1 file read' ) expect(fsReadMessage).to.exist const expectedPath = path.join(rootPath, 'test.py') - const actualPaths = fsReadMessage?.fileList?.filePaths?.map(normalizePath) || [] + const actualPaths = fsReadMessage?.header?.fileList?.filePaths?.map(normalizePath) || [] expect(actualPaths).to.include.members([normalizePath(expectedPath)]) expect(fsReadMessage?.messageId?.startsWith('tooluse_')).to.be.true }) @@ -191,10 +191,10 @@ describe('Q Agentic Chat Server Integration Tests', async () => { expect(decryptedResult.additionalMessages).to.be.an('array') const listDirectoryMessage = decryptedResult.additionalMessages?.find( - msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 directory listed' + msg => msg.type === 'tool' && msg.header?.body === '1 directory listed' ) expect(listDirectoryMessage).to.exist - const actualPaths = listDirectoryMessage?.fileList?.filePaths?.map(normalizePath) || [] + const actualPaths = listDirectoryMessage?.header?.fileList?.filePaths?.map(normalizePath) || [] expect(actualPaths).to.include.members([normalizePath(rootPath)]) expect(listDirectoryMessage?.messageId?.startsWith('tooluse_')).to.be.true }) @@ -371,11 +371,12 @@ describe('Q Agentic Chat Server Integration Tests', async () => { expect(decryptedResult.additionalMessages).to.be.an('array') const fileSearchMessage = decryptedResult.additionalMessages?.find( - msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 directory searched' + msg => msg.type === 'tool' && msg.header?.body === 'Searched for "test" in ' ) expect(fileSearchMessage).to.exist expect(fileSearchMessage?.messageId?.startsWith('tooluse_')).to.be.true - const actualPaths = fileSearchMessage?.fileList?.filePaths?.map(normalizePath) || [] + expect(fileSearchMessage?.header?.status?.text).to.equal('3 results found') + const actualPaths = fileSearchMessage?.header?.fileList?.filePaths?.map(normalizePath) || [] expect(actualPaths).to.include.members([normalizePath(rootPath)]) }) }) diff --git a/package-lock.json b/package-lock.json index 1d8f367c34..5fc601a1bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "name": "@aws/lsp-antlr4-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-antlr4": "*", "antlr4-c3": "^3.4.1", "antlr4ng": "^3.0.4" @@ -71,7 +71,7 @@ "name": "@aws/lsp-buildspec-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-buildspec": "^0.0.1" } }, @@ -79,7 +79,7 @@ "name": "@aws/lsp-cloudformation-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-cloudformation": "^0.0.1" } }, @@ -87,7 +87,7 @@ "name": "@aws/lsp-codewhisperer-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-codewhisperer": "*", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", @@ -120,7 +120,7 @@ "name": "@aws/lsp-identity-runtimes", "version": "0.1.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-identity": "^0.0.1" } }, @@ -128,7 +128,7 @@ "name": "@aws/lsp-json-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-json": "*" }, "devDependencies": { @@ -148,7 +148,7 @@ "name": "@aws/lsp-notification-runtimes", "version": "0.1.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-notification": "^0.0.1" } }, @@ -156,7 +156,7 @@ "name": "@aws/lsp-partiql-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.120", + "@aws/language-server-runtimes": "^0.2.125", "@aws/lsp-partiql": "^0.0.5" }, "devDependencies": { @@ -181,7 +181,7 @@ "name": "@aws/lsp-s3-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-s3": "^0.0.1" }, "bin": { @@ -192,7 +192,7 @@ "name": "@aws/lsp-yaml-json-webworker", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*" }, @@ -212,7 +212,7 @@ "name": "@aws/lsp-yaml-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-yaml": "*" }, "devDependencies": { @@ -234,7 +234,7 @@ "version": "0.0.1", "dependencies": { "@aws/hello-world-lsp": "^0.0.1", - "@aws/language-server-runtimes": "^0.2.121" + "@aws/language-server-runtimes": "^0.2.127" }, "devDependencies": { "@types/chai": "^4.3.5", @@ -251,13 +251,13 @@ }, "chat-client": { "name": "@aws/chat-client", - "version": "0.1.30", + "version": "0.1.33", "license": "Apache-2.0", "dependencies": { "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/language-server-runtimes-types": "^0.1.50", - "@aws/mynah-ui": "^4.36.2" + "@aws/mynah-ui": "^4.36.5" }, "devDependencies": { "@types/jsdom": "^21.1.6", @@ -280,7 +280,7 @@ "@aws-sdk/credential-providers": "^3.731.1", "@aws-sdk/types": "^3.734.0", "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@types/uuid": "^9.0.8", "@types/vscode": "^1.98.0", "jose": "^5.2.4", @@ -293,10 +293,10 @@ }, "core/aws-lsp-core": { "name": "@aws/lsp-core", - "version": "0.0.13", + "version": "0.0.14", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@gerhobbelt/gitignore-parser": "^0.2.0-9", "cross-spawn": "7.0.6", "jose": "^5.2.4", @@ -327,7 +327,7 @@ "name": "@aws/q-agentic-chat-server-integration-tests", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "*" }, "devDependencies": { @@ -4036,12 +4036,11 @@ "link": true }, "node_modules/@aws/language-server-runtimes": { - "version": "0.2.121", - "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.121.tgz", - "integrity": "sha512-DDh3ICNVoEi4nhp4JdkkPTsdlHsF0yt6VgxlMGwP90N1hjA4xVESSSla9pB0TcuMPKlmXqOPQGQvkph9FKerVw==", - "license": "Apache-2.0", + "version": "0.2.127", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.127.tgz", + "integrity": "sha512-UWCfv49MYaBhxArVBWTEw2XVfIyunbm6EfS9AxSLPudcwrpOg3KAVLooXearmyM/r2hgNDGCQYI8HuKf5FAnew==", "dependencies": { - "@aws/language-server-runtimes-types": "^0.1.53", + "@aws/language-server-runtimes-types": "^0.1.56", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.200.0", "@opentelemetry/core": "^2.0.0", @@ -4056,7 +4055,7 @@ "hpagent": "^1.2.0", "jose": "^5.9.6", "mac-ca": "^3.1.1", - "os-proxy-config": "^1.1.2", + "registry-js": "^1.16.1", "rxjs": "^7.8.2", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", @@ -4068,9 +4067,9 @@ } }, "node_modules/@aws/language-server-runtimes-types": { - "version": "0.1.53", - "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.1.53.tgz", - "integrity": "sha512-6KCe/YsqF0SciXm8qg/qVuDXGwQqJgRaqrT6YhZUjqs3mclG9G6Gdwu9YEi8t/NYobNWKw0E+aCXW2TFxJgr7A==", + "version": "0.1.56", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.1.56.tgz", + "integrity": "sha512-Md/L750JShCHUsCQUJva51Ofkn/GDBEX8PpZnWUIVqkpddDR00SLQS2smNf4UHtKNJ2fefsfks/Kqfuatjkjvg==", "license": "Apache-2.0", "dependencies": { "vscode-languageserver-textdocument": "^1.0.12", @@ -4204,9 +4203,9 @@ "link": true }, "node_modules/@aws/mynah-ui": { - "version": "4.36.2", - "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.36.2.tgz", - "integrity": "sha512-3ibfK2CTj7dlFFdgTIE1DdEyDpy+P3hdP/Fmlx76T9GGSYiGHqwunDSi59L1P61Kj46WADBrQ52mLUQ6FR8Rzg==", + "version": "4.36.5", + "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.36.5.tgz", + "integrity": "sha512-HMXqvSpZT84mpY67ChzRDrd73Y9AFZVZ8RcOJ/rNWIXR44uryfNFg2nrvoP4GSn2P+kU8WIPGChHGmyX9N0UgA==", "hasInstallScript": true, "license": "Apache License 2.0", "dependencies": { @@ -20292,12 +20291,6 @@ "undici": "^6.16.1" } }, - "node_modules/mac-system-proxy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mac-system-proxy/-/mac-system-proxy-1.0.4.tgz", - "integrity": "sha512-IAkNLxXZrYuM99A2OhPrvUoAxohsxQciJh2D2xnD+R6vypn/AVyOYLsbZsMVCS/fEbLIe67nQ8krEAfqP12BVg==", - "license": "Apache-2.0" - }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -21387,16 +21380,6 @@ "node": ">=0.10.0" } }, - "node_modules/os-proxy-config": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/os-proxy-config/-/os-proxy-config-1.1.2.tgz", - "integrity": "sha512-sV7htE8y6NQORU0oKOUGTwQYe1gSFK3a3Z1i4h6YaqdrA9C0JIsUPQAqEkO8ejjYbRrQ+jsnks5qjtisr7042Q==", - "license": "Apache-2.0", - "dependencies": { - "mac-system-proxy": "^1.0.0", - "windows-system-proxy": "^1.0.0" - } - }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -28090,15 +28073,6 @@ "node": ">=4" } }, - "node_modules/windows-system-proxy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/windows-system-proxy/-/windows-system-proxy-1.0.0.tgz", - "integrity": "sha512-qd1WfyX9gjAqI36RHt95di2+FBr74DhvELd1EASgklCGScjwReHnWnXfUyabp/CJWl/IdnkUzG0Ub6Cv2R4KJQ==", - "license": "Apache-2.0", - "dependencies": { - "registry-js": "^1.15.1" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -28630,11 +28604,11 @@ }, "server/aws-lsp-antlr4": { "name": "@aws/lsp-antlr4", - "version": "0.1.17", + "version": "0.1.18", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13" + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14" }, "devDependencies": { "@babel/plugin-transform-modules-commonjs": "^7.24.1", @@ -28675,7 +28649,7 @@ "name": "@aws/lsp-buildspec", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*", "vscode-languageserver": "^9.0.1", @@ -28686,7 +28660,7 @@ "name": "@aws/lsp-cloudformation", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "*", "@aws/lsp-json": "*", "vscode-languageserver": "^9.0.1", @@ -28695,7 +28669,7 @@ }, "server/aws-lsp-codewhisperer": { "name": "@aws/lsp-codewhisperer", - "version": "0.0.71", + "version": "0.0.75", "bundleDependencies": [ "@amzn/codewhisperer-streaming", "@amzn/amazon-q-developer-streaming-client" @@ -28708,8 +28682,8 @@ "@aws-sdk/util-arn-parser": "^3.723.0", "@aws-sdk/util-retry": "^3.374.0", "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13", + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14", "@modelcontextprotocol/sdk": "^1.15.0", "@smithy/node-http-handler": "^2.5.0", "adm-zip": "^0.5.10", @@ -28848,7 +28822,7 @@ "dependencies": { "@aws-sdk/client-sso-oidc": "^3.616.0", "@aws-sdk/token-providers": "^3.744.0", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "^0.0.12", "@smithy/node-http-handler": "^3.2.5", "@smithy/shared-ini-file-loader": "^4.0.1", @@ -28910,11 +28884,11 @@ }, "server/aws-lsp-json": { "name": "@aws/lsp-json", - "version": "0.1.17", + "version": "0.1.18", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13", + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8" }, @@ -28930,7 +28904,7 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "^0.0.12", "vscode-languageserver": "^9.0.1" }, @@ -28988,10 +28962,10 @@ }, "server/aws-lsp-partiql": { "name": "@aws/lsp-partiql", - "version": "0.0.16", + "version": "0.0.17", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "antlr4-c3": "3.4.2", "antlr4ng": "3.0.14", "web-tree-sitter": "0.22.6" @@ -29013,7 +28987,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.623.0", "@aws-sdk/types": "^3.734.0", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "^0.0.12", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8" @@ -29040,12 +29014,12 @@ }, "server/aws-lsp-yaml": { "name": "@aws/lsp-yaml", - "version": "0.1.17", + "version": "0.1.18", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13", + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8", "yaml-language-server": "1.13.0" @@ -29058,7 +29032,7 @@ "name": "@amzn/device-sso-auth-lsp", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "vscode-languageserver": "^9.0.1" }, "devDependencies": { @@ -29069,7 +29043,7 @@ "name": "@aws/hello-world-lsp", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "vscode-languageserver": "^9.0.1" }, "devDependencies": { diff --git a/server/aws-lsp-antlr4/CHANGELOG.md b/server/aws-lsp-antlr4/CHANGELOG.md index bde43f2c3c..e1458575e1 100644 --- a/server/aws-lsp-antlr4/CHANGELOG.md +++ b/server/aws-lsp-antlr4/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.1.18](https://github.com/aws/language-servers/compare/lsp-antlr4/v0.1.17...lsp-antlr4/v0.1.18) (2025-08-19) + + +### Bug Fixes + +* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @aws/lsp-core bumped from ^0.0.13 to ^0.0.14 + ## [0.1.17](https://github.com/aws/language-servers/compare/lsp-antlr4/v0.1.16...lsp-antlr4/v0.1.17) (2025-08-04) diff --git a/server/aws-lsp-antlr4/package.json b/server/aws-lsp-antlr4/package.json index aadfe48b08..2ca49593d7 100644 --- a/server/aws-lsp-antlr4/package.json +++ b/server/aws-lsp-antlr4/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-antlr4", - "version": "0.1.17", + "version": "0.1.18", "description": "ANTLR4 language server", "main": "out/index.js", "repository": { @@ -28,8 +28,8 @@ "clean": "rm -rf node_modules" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13" + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14" }, "peerDependencies": { "antlr4-c3": ">=3.4 < 4", diff --git a/server/aws-lsp-buildspec/package.json b/server/aws-lsp-buildspec/package.json index 92960545fb..9754645b7d 100644 --- a/server/aws-lsp-buildspec/package.json +++ b/server/aws-lsp-buildspec/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*", "vscode-languageserver": "^9.0.1", diff --git a/server/aws-lsp-cloudformation/package.json b/server/aws-lsp-cloudformation/package.json index 2dd24848b8..13be6a4859 100644 --- a/server/aws-lsp-cloudformation/package.json +++ b/server/aws-lsp-cloudformation/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "*", "@aws/lsp-json": "*", "vscode-languageserver": "^9.0.1", diff --git a/server/aws-lsp-codewhisperer/CHANGELOG.md b/server/aws-lsp-codewhisperer/CHANGELOG.md index 4d89e37f70..a70284a4c2 100644 --- a/server/aws-lsp-codewhisperer/CHANGELOG.md +++ b/server/aws-lsp-codewhisperer/CHANGELOG.md @@ -1,5 +1,84 @@ # Changelog +## [0.0.75](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.74...lsp-codewhisperer/v0.0.75) (2025-08-21) + + +### Bug Fixes + +* **amazonq:** don't let flare send discard for the still valid suggestion in JB ([#2145](https://github.com/aws/language-servers/issues/2145)) ([0767e07](https://github.com/aws/language-servers/commit/0767e074c91682a91d2fe7a6b2a7369c4dea280c)) + +## [0.0.74](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.73...lsp-codewhisperer/v0.0.74) (2025-08-19) + + +### Features + +* **amazonq:** added mcp admin level configuration with GetProfile ([#2000](https://github.com/aws/language-servers/issues/2000)) ([fd6e9a8](https://github.com/aws/language-servers/commit/fd6e9a829c6229c276de5340dffce52b426a864d)) +* **amazonq:** read tool ui revamp ([#2113](https://github.com/aws/language-servers/issues/2113)) ([#2121](https://github.com/aws/language-servers/issues/2121)) ([93cf229](https://github.com/aws/language-servers/commit/93cf229149ba60491f9f5763793db4a9f570b611)) +* remove project type validation from LSP layer ([#2103](https://github.com/aws/language-servers/issues/2103)) ([d397161](https://github.com/aws/language-servers/commit/d397161cc3448c63016e27f5ac2a1917cdaae1cb)) + + +### Bug Fixes + +* **amazonq:** add server side control for WCS features ([#2128](https://github.com/aws/language-servers/issues/2128)) ([5e4435d](https://github.com/aws/language-servers/commit/5e4435dfaea7bf8c00e6a27b9bb0d40f699d4e01)) +* **amazonq:** fix regression of mcp config in agent config ([#2101](https://github.com/aws/language-servers/issues/2101)) ([e4e8bbb](https://github.com/aws/language-servers/commit/e4e8bbb89e4b597926582bead2b14ffc43f2a7f8)) +* **amazonq:** handle case where multiple rules are provided with the same name ([#2118](https://github.com/aws/language-servers/issues/2118)) ([0e23e2d](https://github.com/aws/language-servers/commit/0e23e2d29b8cad14403d372b9bbb08ca8ffa7ac7)) +* **amazonq:** persist mcp configs in agent json on start-up ([#2112](https://github.com/aws/language-servers/issues/2112)) ([817cfe2](https://github.com/aws/language-servers/commit/817cfe2656cb1deec6111c699c4ba46b4ba53e00)) +* empty userTriggerDecision not being sent for NEP code path ([#2140](https://github.com/aws/language-servers/issues/2140)) ([b8e5268](https://github.com/aws/language-servers/commit/b8e52682ac2b2337e1d0a32759e8beccde889cee)) +* fix for button text and remove profilearn caching ([#2137](https://github.com/aws/language-servers/issues/2137)) ([2a4171a](https://github.com/aws/language-servers/commit/2a4171a74c15c23c23c481060496162bcc9e6284)) +* fix to add disk caching for mcp admin state ([#2139](https://github.com/aws/language-servers/issues/2139)) ([f947e1a](https://github.com/aws/language-servers/commit/f947e1a9da4431d6089b22825f992010c30a470b)) +* fix to turn on and off MCP servers incase of error based on last state ([#2143](https://github.com/aws/language-servers/issues/2143)) ([04588df](https://github.com/aws/language-servers/commit/04588dfc33f0d85dbd488814a474b5e354398df0)) +* proper path handling for additional context ([#2129](https://github.com/aws/language-servers/issues/2129)) ([971eaa5](https://github.com/aws/language-servers/commit/971eaa505d948e9d2090c85f9b965f554ea7f2c8)) +* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289)) + + +### Performance Improvements + +* remove edit completion retry mechanism on document change ([#2124](https://github.com/aws/language-servers/issues/2124)) ([963b6e9](https://github.com/aws/language-servers/commit/963b6e9b7887da23a85a826c55a6ed95ff36d956)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @aws/lsp-core bumped from ^0.0.13 to ^0.0.14 + +## [0.0.73](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.72...lsp-codewhisperer/v0.0.73) (2025-08-11) + + +### Features + +* **amazonq:** read tool ui revamp ([c65428b](https://github.com/aws/language-servers/commit/c65428bab2cf5e47badf1e3a9453babcf881e60c)) + + +### Bug Fixes + +* **amazonq:** add fallback classpath generation ([#2077](https://github.com/aws/language-servers/issues/2077)) ([3a6ef14](https://github.com/aws/language-servers/commit/3a6ef14e78fa2e75b837bba6524751d65038f416)) +* **amazonq:** emit failed status for amazonq_invokeLLM ([#2071](https://github.com/aws/language-servers/issues/2071)) ([ee52a41](https://github.com/aws/language-servers/commit/ee52a41bc869b275fff708d7955b59f43b93bbd4)) +* **amazonq:** fix fallout of [#2051](https://github.com/aws/language-servers/issues/2051) ([#2057](https://github.com/aws/language-servers/issues/2057)) ([565066b](https://github.com/aws/language-servers/commit/565066bb61adda60333c9646db958d4208bcc8af)) +* **amazonq:** leverage lcs to find the chars added and removed ([#2092](https://github.com/aws/language-servers/issues/2092)) ([40379a8](https://github.com/aws/language-servers/commit/40379a887f8d42cc184239ca3175b4e673cc5286)) +* **amazonq:** skips continuous monitoring when WCS sees workspace as idle ([#2066](https://github.com/aws/language-servers/issues/2066)) ([9cb959d](https://github.com/aws/language-servers/commit/9cb959d4cc450d0907f8bf5265ba01d2aa68bcd0)) +* creating a new sesion for Edits trigger with next token ([#2094](https://github.com/aws/language-servers/issues/2094)) ([1da8730](https://github.com/aws/language-servers/commit/1da8730b6ed6ad53b6561368bf722e56d59596a4)) +* remove edit cache logic ([#2079](https://github.com/aws/language-servers/issues/2079)) ([9bc5b9c](https://github.com/aws/language-servers/commit/9bc5b9c1d77e5fee6f518f7f5016d3a0043a5a77)) +* sessionManager misused because there are 2 types of manager now ([#2090](https://github.com/aws/language-servers/issues/2090)) ([8db059a](https://github.com/aws/language-servers/commit/8db059ab83d94fd7c3ba3eb265044add31c80aea)) +* update client name to support Sagemaker AI origin for agentic chat ([#2093](https://github.com/aws/language-servers/issues/2093)) ([a746fe8](https://github.com/aws/language-servers/commit/a746fe845d5e09563b475f01ce44059dca9fd10f)) + +## [0.0.72](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.71...lsp-codewhisperer/v0.0.72) (2025-08-06) + + +### Features + +* add support for SMUS Q CodeEditor client to send MD IDE origin ([#2032](https://github.com/aws/language-servers/issues/2032)) ([a8725b4](https://github.com/aws/language-servers/commit/a8725b4b7dcb7718864620721aa3633151e8877b)) +* **amazonq:** enable sonnet 4 for fra region ([#2069](https://github.com/aws/language-servers/issues/2069)) ([3a4b8df](https://github.com/aws/language-servers/commit/3a4b8df981b2c3b0532360a11472169fffec7924)) + + +### Bug Fixes + +* **amazonq:** add distinctive identifier for cloud trail ([#2059](https://github.com/aws/language-servers/issues/2059)) ([18bbc2c](https://github.com/aws/language-servers/commit/18bbc2c54f5cc72e2624020fc17214c448926b0e)) +* **amazonq:** fix to add disable/enable feature back to mcp servers ([#2052](https://github.com/aws/language-servers/issues/2052)) ([c03e017](https://github.com/aws/language-servers/commit/c03e017b9ccbbbb9c80a3c3afd5da38a50bd6cff)) +* **amazonq:** make display findings tool run more often ([#2067](https://github.com/aws/language-servers/issues/2067)) ([479ccd0](https://github.com/aws/language-servers/commit/479ccd0a1b8b7e98684275c66274d284599c5933)) +* outdated history when trimming happens, add missing metric for compaction ([#2047](https://github.com/aws/language-servers/issues/2047)) ([8390f66](https://github.com/aws/language-servers/commit/8390f6686c804dfbeff91018635df21e9dd89236)) +* should keep reporting UTDE telemetry if there are still pending Edits suggestions ([#2051](https://github.com/aws/language-servers/issues/2051)) ([78c67b1](https://github.com/aws/language-servers/commit/78c67b1a29821f54006d160695e997870d17f3b5)) + ## [0.0.71](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.70...lsp-codewhisperer/v0.0.71) (2025-08-04) diff --git a/server/aws-lsp-codewhisperer/package.json b/server/aws-lsp-codewhisperer/package.json index 2c7366ff34..83ff1cdaee 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.71", + "version": "0.0.75", "description": "CodeWhisperer Language Server", "main": "out/index.js", "repository": { @@ -36,8 +36,8 @@ "@aws-sdk/util-arn-parser": "^3.723.0", "@aws-sdk/util-retry": "^3.374.0", "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13", + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14", "@modelcontextprotocol/sdk": "^1.15.0", "@smithy/node-http-handler": "^2.5.0", "adm-zip": "^0.5.10", diff --git a/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json b/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json index 88e6e8d177..7e0533b0f5 100644 --- a/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json +++ b/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json @@ -516,6 +516,37 @@ ], "documentation": "