diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 49f118f219..10e6c765df 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,8 +1,8 @@ { - "chat-client": "0.1.33", + "chat-client": "0.1.34", "core/aws-lsp-core": "0.0.14", "server/aws-lsp-antlr4": "0.1.18", - "server/aws-lsp-codewhisperer": "0.0.75", + "server/aws-lsp-codewhisperer": "0.0.77", "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-codewhisperer-runtimes/src/version.json b/app/aws-lsp-codewhisperer-runtimes/src/version.json index 93401eb637..e842765e3f 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.29.0" + "agenticChat": "1.30.0" } diff --git a/chat-client/CHANGELOG.md b/chat-client/CHANGELOG.md index 995ddc8ae6..1c516246c6 100644 --- a/chat-client/CHANGELOG.md +++ b/chat-client/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [0.1.34](https://github.com/aws/language-servers/compare/chat-client/v0.1.33...chat-client/v0.1.34) (2025-08-27) + + +### Features + +* Auto fetch models from listAvailableModels API ([#2171](https://github.com/aws/language-servers/issues/2171)) ([8600c52](https://github.com/aws/language-servers/commit/8600c524877abb459e9338399352446c0dcff6f0)) + + +### Bug Fixes + +* **amazonq:** disable typewriter animation ([#2160](https://github.com/aws/language-servers/issues/2160)) ([db45d01](https://github.com/aws/language-servers/commit/db45d01adba10e8a04d868e1062f899df4f5b7e4)) + ## [0.1.33](https://github.com/aws/language-servers/compare/chat-client/v0.1.32...chat-client/v0.1.33) (2025-08-19) diff --git a/chat-client/package.json b/chat-client/package.json index e4250d42a1..b838360175 100644 --- a/chat-client/package.json +++ b/chat-client/package.json @@ -1,6 +1,6 @@ { "name": "@aws/chat-client", - "version": "0.1.33", + "version": "0.1.34", "description": "AWS Chat Client", "main": "out/index.js", "repository": { 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/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 d07d0ab291..15cbeae7ff 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. 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 3a6012471a..6df349896c 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 c36dfad976..abd010436e 100644 --- a/chat-client/src/client/texts/modelSelection.test.ts +++ b/chat-client/src/client/texts/modelSelection.test.ts @@ -1,60 +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 all 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, 2, 'should have 2 option') - - const modelIds = euCentral1ModelSelection.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' - ) - }) - }) - 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 18df833419..a1d0247875 100644 --- a/chat-client/src/client/texts/modelSelection.ts +++ b/chat-client/src/client/texts/modelSelection.ts @@ -13,7 +13,7 @@ type ModelDetails = { } const modelRecord: Record = { - [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'Claude Sonnet 3.7' }, + [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'Claude 3.7 Sonnet' }, [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { label: 'Claude Sonnet 4' }, } @@ -22,24 +22,16 @@ const modelOptions = Object.entries(modelRecord).map(([value, { label }]) => ({ label, })) -const modelSelection: ChatItemFormItem = { +export const modelSelection: ChatItemFormItem = { type: 'select', id: 'model-selection', - options: modelOptions, mandatory: true, hideMandatoryIcon: true, + options: modelOptions, border: false, autoWidth: true, } -/** - * @deprecated use aws/chat/listAvailableModels server request instead - */ -export const modelSelectionForRegion: Record = { - 'us-east-1': modelSelection, - 'eu-central-1': modelSelection, -} - export const getModelSelectionChatItem = (modelName: string): ChatItem => ({ type: ChatItemType.DIRECTIVE, contentHorizontalAlignment: 'center', diff --git a/package-lock.json b/package-lock.json index 5fc601a1bc..70518fd0ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -251,7 +251,7 @@ }, "chat-client": { "name": "@aws/chat-client", - "version": "0.1.33", + "version": "0.1.34", "license": "Apache-2.0", "dependencies": { "@aws/chat-client-ui-types": "^0.1.56", @@ -28669,7 +28669,7 @@ }, "server/aws-lsp-codewhisperer": { "name": "@aws/lsp-codewhisperer", - "version": "0.0.75", + "version": "0.0.77", "bundleDependencies": [ "@amzn/codewhisperer-streaming", "@amzn/amazon-q-developer-streaming-client" diff --git a/server/aws-lsp-codewhisperer/CHANGELOG.md b/server/aws-lsp-codewhisperer/CHANGELOG.md index a70284a4c2..4bb50ee8fd 100644 --- a/server/aws-lsp-codewhisperer/CHANGELOG.md +++ b/server/aws-lsp-codewhisperer/CHANGELOG.md @@ -1,5 +1,60 @@ # Changelog +## [0.0.77](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.76...lsp-codewhisperer/v0.0.77) (2025-09-02) + + +### Features + +* passing suggestionTypes and pluginVersion/lspVersion to STE ([#2180](https://github.com/aws/language-servers/issues/2180)) ([66742ad](https://github.com/aws/language-servers/commit/66742adfc44f33efbd8dd33b803000e08241e5ce)) + + +### Bug Fixes + +* auto trigger should only respect previous decisions in the past 2mins ([#2189](https://github.com/aws/language-servers/issues/2189)) ([852b21b](https://github.com/aws/language-servers/commit/852b21b66f793102c52e35c2baec07a772e5134a)) +* compact UI is not updated correctly when multiple nudges are displayed ([#2192](https://github.com/aws/language-servers/issues/2192)) ([ef7d793](https://github.com/aws/language-servers/commit/ef7d7931954f5083e4a5c358e67c6dc652fa1a40)) +* emit acceptedLineCount metric and AgenticCodeAccepted interaction type ([#2167](https://github.com/aws/language-servers/issues/2167)) ([c53f672](https://github.com/aws/language-servers/commit/c53f672b6173ebda530917ccb4e0c2f26f5c8f79)) +* emit errorMessage in addMessage ([#2197](https://github.com/aws/language-servers/issues/2197)) ([58f2064](https://github.com/aws/language-servers/commit/58f20649d345f159080006120e23cde559826df1)) +* fix calculation for num-lines contributed by the LLM ([#2191](https://github.com/aws/language-servers/issues/2191)) ([fd71e6c](https://github.com/aws/language-servers/commit/fd71e6cf3fc843242936564061061418edf83f56)) +* should send classifier score after taking sigmoid ([#2188](https://github.com/aws/language-servers/issues/2188)) ([f4e2e6e](https://github.com/aws/language-servers/commit/f4e2e6e885e665834a5d7b7cbb5f4ba4ff9bbb65)) + + +### Performance Improvements + +* only process edit requests 1 at a time ([#2187](https://github.com/aws/language-servers/issues/2187)) ([b497540](https://github.com/aws/language-servers/commit/b4975409a3ed518550290b72ac310895a293be4b)) + + +### Reverts + +* PR 2172 dedupe openTabs supplemental contexts ([#2194](https://github.com/aws/language-servers/issues/2194)) ([94723d4](https://github.com/aws/language-servers/commit/94723d46073a1ea8211e7ae8f9dfce3fcb809604)) + +## [0.0.76](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.75...lsp-codewhisperer/v0.0.76) (2025-08-27) + + +### Features + +* add basic OAuth client for remote MCP ([#2136](https://github.com/aws/language-servers/issues/2136)) ([2fb896e](https://github.com/aws/language-servers/commit/2fb896e094de0bc5a1b4881067e7dcceb3826015)) +* **amazonq:** emit metric for each issue ([#2179](https://github.com/aws/language-servers/issues/2179)) ([5a3f481](https://github.com/aws/language-servers/commit/5a3f481ebe8c6033e3833abcd81799d26c2aa03e)) +* Auto fetch models from listAvailableModels API ([#2171](https://github.com/aws/language-servers/issues/2171)) ([8600c52](https://github.com/aws/language-servers/commit/8600c524877abb459e9338399352446c0dcff6f0)) +* disable pkce flow during plugin load ([#2153](https://github.com/aws/language-servers/issues/2153)) ([71b3595](https://github.com/aws/language-servers/commit/71b35952333e7581921644ce40fabbc1e6d3c02f)) +* update MCP manager and utilities ([#2158](https://github.com/aws/language-servers/issues/2158)) ([b99df82](https://github.com/aws/language-servers/commit/b99df82826d0ba1a1d52df578cb80674c90505b9)) + + +### Bug Fixes + +* adding streakTracker to track streakLength across Completions and Edits ([#2147](https://github.com/aws/language-servers/issues/2147)) ([a6c64f2](https://github.com/aws/language-servers/commit/a6c64f2995a17697e3d71d30a1f411f5cf0db279)) +* **amazonq:** dedupe openTabs supplemental contexts ([#2172](https://github.com/aws/language-servers/issues/2172)) ([aa87ae2](https://github.com/aws/language-servers/commit/aa87ae2bd95edc1f38bf90f56093c5bf5ff18c53)) +* **amazonq:** fix for mcp servers operations to edit server config only ([#2165](https://github.com/aws/language-servers/issues/2165)) ([d28df09](https://github.com/aws/language-servers/commit/d28df09ae41871430cd53064eac1f3050c95ea84)) +* **amazonq:** fix to add mcp server tool error handling and status for card ([#2176](https://github.com/aws/language-servers/issues/2176)) ([23f5ec3](https://github.com/aws/language-servers/commit/23f5ec343cb4e0de32926204dbcf99e51af829f9)) +* **amazonq:** status message update for mcp tool permission accpetance ([#2178](https://github.com/aws/language-servers/issues/2178)) ([4893344](https://github.com/aws/language-servers/commit/489334466fa084774d6e4737569468d654dc6359)) +* fix pkce windows url path ([#2173](https://github.com/aws/language-servers/issues/2173)) ([d7b184c](https://github.com/aws/language-servers/commit/d7b184cb12979877722fa0293e9aebec91ff2c18)) +* multiple fixes on auth flow edge cases ([#2155](https://github.com/aws/language-servers/issues/2155)) ([472220a](https://github.com/aws/language-servers/commit/472220a745cff4fe91a2cabae4ae059a164ceddd)) +* reduce auto trigger frequency for VSC ([#2168](https://github.com/aws/language-servers/issues/2168)) ([00e11ff](https://github.com/aws/language-servers/commit/00e11ff48eafaa0baec48177fa4aa6d60048af2f)) + + +### Reverts + +* reduce auto trigger frequency for VSC ([#2168](https://github.com/aws/language-servers/issues/2168))" ([#2177](https://github.com/aws/language-servers/issues/2177)) ([08720c6](https://github.com/aws/language-servers/commit/08720c6c3fa83f9b3b6775d4ae4d848ce145b94b)) + ## [0.0.75](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.74...lsp-codewhisperer/v0.0.75) (2025-08-21) diff --git a/server/aws-lsp-codewhisperer/package.json b/server/aws-lsp-codewhisperer/package.json index 83ff1cdaee..aba60487ae 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.75", + "version": "0.0.77", "description": "CodeWhisperer Language Server", "main": "out/index.js", "repository": { 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 a5704fca16..f54c13cbdd 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 @@ -1890,6 +1890,10 @@ "type": "string", "enum": ["BLOCK", "LINE"] }, + "SuggestionType": { + "type": "string", + "enum": ["COMPLETIONS", "EDITS"] + }, "Completions": { "type": "list", "member": { @@ -3622,6 +3626,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

" @@ -3955,6 +3963,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

" @@ -3972,6 +3984,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": { @@ -4803,6 +4822,12 @@ }, "profileArn": { "shape": "ProfileArn" + }, + "languageModelId": { + "shape": "ModelId" + }, + "clientType": { + "shape": "Origin" } } }, @@ -6284,6 +6309,12 @@ }, "ideVersion": { "shape": "String" + }, + "pluginVersion": { + "shape": "String" + }, + "lspVersion": { + "shape": "String" } } }, @@ -6525,6 +6556,9 @@ }, "streakLength": { "shape": "UserTriggerDecisionEventStreakLengthInteger" + }, + "suggestionType": { + "shape": "SuggestionType" } } }, 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 c885612888..4c8018c84e 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'; @@ -548,6 +549,7 @@ declare namespace CodeWhispererBearerTokenClient { } export type CompletionContentString = string; export type CompletionType = "BLOCK"|"LINE"|string; + export type SuggestionType = "COMPLETIONS"|"EDITS"|string; export type Completions = Completion[]; export interface ConsoleState { region?: String; @@ -1132,6 +1134,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 */ @@ -1240,6 +1246,10 @@ declare namespace CodeWhispererBearerTokenClient { * Unique identifier for the model */ modelId: ModelId; + /** + * User-facing display name + */ + modelName?: ModelName; /** * Description of the model */ @@ -1250,6 +1260,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 @@ -1528,6 +1539,8 @@ declare namespace CodeWhispererBearerTokenClient { codeScanName?: CodeScanName; codeDiffMetadata?: CodeDiffMetadata; profileArn?: ProfileArn; + languageModelId?: ModelId; + clientType?: Origin; } export type StartCodeAnalysisRequestClientTokenString = string; export interface StartCodeAnalysisResponse { @@ -2004,6 +2017,8 @@ declare namespace CodeWhispererBearerTokenClient { product: UserContextProductString; clientId?: UUID; ideVersion?: String; + pluginVersion?: String; + lspVersion?: String; } export type UserContextProductString = string; export interface UserInputMessage { @@ -2117,6 +2132,7 @@ declare namespace CodeWhispererBearerTokenClient { addedCharacterCount?: UserTriggerDecisionEventAddedCharacterCountInteger; deletedCharacterCount?: UserTriggerDecisionEventDeletedCharacterCountInteger; streakLength?: UserTriggerDecisionEventStreakLengthInteger; + suggestionType?: SuggestionType; } export type UserTriggerDecisionEventAddedCharacterCountInteger = number; export type UserTriggerDecisionEventDeletedCharacterCountInteger = number; 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 a307ce3fc6..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 @@ -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, @@ -368,17 +369,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 }) @@ -3003,153 +2993,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 all available 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, 2) - assert.strictEqual(result.selectedModelId, 'CLAUDE_SONNET_4_20250514_V1_0') + await chatController.onListAvailableModels({ tabId: mockTabId }) - // 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 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_SONNET_4_20250514_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', + }) - it('should fallback to latest available model when saved model is not available in current region', async () => { - // Import the module to stub - const modelSelection = await import('./constants/modelSelection') + // 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' }, + ]) - // Create a mock region with only Claude 3.7 - const mockModelOptionsForRegion = { - ...modelSelection.MODEL_OPTIONS_FOR_REGION, - 'test-region-limited': [ - { - id: 'CLAUDE_3_7_SONNET_20250219_V1_0', - name: 'Claude Sonnet 3.7', - }, - ], - } + // Verify cache was updated + sinon.assert.calledOnceWithExactly(setCachedModelsStub, result.models, 'claude-3-sonnet') + }) - // Stub the MODEL_OPTIONS_FOR_REGION - const modelOptionsStub = sinon - .stub(modelSelection, 'MODEL_OPTIONS_FOR_REGION') - .value(mockModelOptionsForRegion) + it('should fall back to hardcoded models when API call fails', async () => { + getConnectionTypeStub.returns('builderId') + listAvailableModelsStub.rejects(new Error('API Error')) - // Set up the region to be the test region (which only has Claude 3.7) - tokenServiceManagerStub.returns('test-region-limited') + const result = await chatController.onListAvailableModels({ tabId: mockTabId }) - // Mock database to return Claude Sonnet 4 (not available in test-region-limited) - 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() - modelOptionsStub.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, 'model1') - // 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-sonnet-4') // FALLBACK_MODEL_RECORD[DEFAULT_MODEL_ID].label + // Verify session modelId is updated + 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 991d95a6ad..e2fba029c4 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, @@ -168,6 +166,7 @@ import { FsWrite, FsWriteParams } from './tools/fsWrite' import { ExecuteBash, ExecuteBashParams } from './tools/executeBash' import { ExplanatoryParams, InvokeOutput, ToolApprovalException } from './tools/toolShared' import { validatePathBasic, validatePathExists, validatePaths as validatePathsSync } from './utils/pathValidation' +import { calculateModifiedLines } from './utils/fileModificationMetrics' import { GrepSearch, SanitizedRipgrepOutput } from './tools/grepSearch' import { FileSearch, FileSearchParams, isFileSearchParams } from './tools/fileSearch' import { FsReplace, FsReplaceParams } from './tools/fsReplace' @@ -222,14 +221,14 @@ import { Message as DbMessage, messageToStreamingMessage, } from './tools/chatDb/util' -import { MODEL_OPTIONS, MODEL_OPTIONS_FOR_REGION } 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 { 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' import { IdleWorkspaceManager } from '../workspaceContext/IdleWorkspaceManager' type ChatHandlers = Omit< @@ -352,7 +351,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, @@ -681,25 +680,133 @@ 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: ', JSON.stringify(responseResult, null, 2)) + 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 = defaultModelId || defaultModelOption?.id 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, } } - const savedModelId = this.#chatHistoryDb.getModelId() - const selectedModelId = - savedModelId && models.some(model => model.id === savedModelId) - ? savedModelId - : getLatestAvailableModel(region).id + // Determine selected model ID based on priority + 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 + + // 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) { + 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) { + selectedModelId = getModelLabel(modelId) + } + // Priority 3: Fall back to default or system default + else { + selectedModelId = defaultModelId || getMappedModelId(DEFAULT_MODEL_ID) + } + } else { + // No user-selected model - use API default or system default + selectedModelId = defaultModelId || getMappedModelId(DEFAULT_MODEL_ID) + } + + // Store the selected model in the session session.modelId = selectedModelId + return { tabId: params.tabId, models: models, @@ -1882,6 +1989,7 @@ export class AgenticChatController implements ChatHandlers { .filter(c => c.type === 'rule') .map(c => ({ path: c.path })) } + initialInput['modelId'] = session.modelId toolUse.input = initialInput } catch (e) { this.#features.logging.warn(`could not parse CodeReview tool input: ${e}`) @@ -1970,6 +2078,17 @@ export class AgenticChatController implements ChatHandlers { this.#abTestingAllocation?.experimentName, this.#abTestingAllocation?.userVariation ) + // Emit acceptedLineCount when write tool is used and code changes are accepted + const acceptedLineCount = calculateModifiedLines(toolUse, doc?.getText()) + await this.#telemetryController.emitInteractWithMessageMetric( + tabId, + { + cwsprChatMessageId: chatResult.messageId ?? toolUse.toolUseId, + cwsprChatInteractionType: ChatInteractionType.AgenticCodeAccepted, + codewhispererCustomizationArn: this.#customizationArn, + }, + acceptedLineCount + ) await chatResultStream.writeResultBlock(chatResult) break case CodeReview.toolName: @@ -2086,6 +2205,50 @@ export class AgenticChatController implements ChatHandlers { ) } + // Handle MCP tool failures + const originalNames = McpManager.instance.getOriginalToolNames(toolUse.name) + if (originalNames && toolUse.toolUseId) { + const { toolName } = originalNames + const cachedToolUse = session.toolUseLookup.get(toolUse.toolUseId) + const cachedButtonBlockId = (cachedToolUse as any)?.cachedButtonBlockId + const customerFacingError = getCustomerFacingErrorMessage(err) + + const errorResult = { + type: 'tool', + messageId: toolUse.toolUseId, + summary: { + content: { + header: { + icon: 'tools', + body: `${toolName}`, + status: { + status: 'error', + icon: 'cancel-circle', + text: 'Error', + description: customerFacingError, + }, + }, + }, + collapsedContent: [ + { + header: { body: 'Parameters' }, + body: `\`\`\`json\n${JSON.stringify(toolUse.input, null, 2)}\n\`\`\``, + }, + { + header: { body: 'Error' }, + body: customerFacingError, + }, + ], + }, + } as ChatResult + + if (cachedButtonBlockId !== undefined) { + await chatResultStream.overwriteResultBlock(errorResult, cachedButtonBlockId) + } else { + await chatResultStream.writeResultBlock(errorResult) + } + } + // display fs write failure status in the UX of that file card if ((toolUse.name === FS_WRITE || toolUse.name === FS_REPLACE) && toolUse.toolUseId) { const existingCard = chatResultStream.getMessageBlockId(toolUse.toolUseId) @@ -2457,7 +2620,7 @@ export class AgenticChatController implements ChatHandlers { status: { status: isAccept ? 'success' : 'error', icon: isAccept ? 'ok' : 'cancel', - text: isAccept ? 'Completed' : 'Rejected', + text: isAccept ? 'Accepted' : 'Rejected', }, fileList: undefined, }, @@ -2544,6 +2707,7 @@ export class AgenticChatController implements ChatHandlers { session.setDeferredToolExecution(messageId, deferred.resolve, deferred.reject) this.#log(`Prompting for compaction approval for messageId: ${messageId}`) await deferred.promise + session.removeDeferredToolExecution(messageId) // Note: we want to overwrite the button block because it already exists in the stream. await resultStream.overwriteResultBlock(this.#getUpdateCompactConfirmResult(messageId), promptBlockId) } @@ -3208,7 +3372,7 @@ export class AgenticChatController implements ChatHandlers { metric: Metric, agenticCodingMode: boolean ): Promise> { - const errorMessage = getErrorMsg(err) + const errorMessage = getErrorMsg(err) ?? GENERIC_ERROR_MS const requestID = getRequestID(err) ?? '' metric.setDimension('cwsprChatResponseCode', getHttpStatusCode(err) ?? 0) metric.setDimension('languageServerVersion', this.#features.runtime.serverInfo.version) @@ -3218,7 +3382,7 @@ export class AgenticChatController implements ChatHandlers { metric.metric.requestIds = [requestID] metric.metric.cwsprChatMessageId = errorMessageId metric.metric.cwsprChatConversationId = conversationId - await this.#telemetryController.emitAddMessageMetric(tabId, metric.metric, 'Failed') + await this.#telemetryController.emitAddMessageMetric(tabId, metric.metric, 'Failed', errorMessage) if (isUsageLimitError(err)) { if (this.#paidTierMode !== 'paidtier') { @@ -3263,7 +3427,7 @@ export class AgenticChatController implements ChatHandlers { tabId, metric.metric, requestID, - errorMessage ?? GENERIC_ERROR_MS, + errorMessage, agenticCodingMode ) } @@ -3580,25 +3744,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 @@ -3611,11 +3756,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({ @@ -3623,11 +3771,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) } @@ -4483,6 +4626,13 @@ export class AgenticChatController implements ChatHandlers { await chatResultStream.overwriteResultBlock(toolResultCard, cachedButtonBlockId) } else { // Fallback to creating a new card + if (toolResultCard.summary?.content?.header) { + toolResultCard.summary.content.header.status = { + status: 'success', + icon: 'ok', + text: 'Completed', + } + } this.#log(`Warning: No blockId found for tool use ${toolUse.toolUseId}, creating new card`) await chatResultStream.writeResultBlock(toolResultCard) } 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 3fe300d2fd..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 @@ -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-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 - 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,47 +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 all models for eu-central-1 region', () => { - const euCentral1Models = MODEL_OPTIONS_FOR_REGION['eu-central-1'] - assert.deepStrictEqual(euCentral1Models, MODEL_OPTIONS, 'us-east-1 should have all models') - assert.strictEqual(euCentral1Models.length, 2, 'us-east-1 should have 2 models') - - const modelIds = euCentral1Models.map(model => model.id) - assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'), 'eu-central-1 should 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 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 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 cbee85f562..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 @@ -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,19 @@ type ModelDetails = { label: string } -const MODEL_RECORD: Record = { - [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'Claude Sonnet 3.7' }, +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 Sonnet 4' }, } -export const MODEL_OPTIONS: ListAvailableModelsResult['models'] = Object.entries(MODEL_RECORD).map( +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( ([value, { label }]) => ({ id: value, name: label, }) ) - -export const MODEL_OPTIONS_FOR_REGION: Record = { - 'us-east-1': MODEL_OPTIONS, - 'eu-central-1': MODEL_OPTIONS, -} 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 dfcf21308c..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 @@ -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' @@ -122,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 @@ -1084,6 +1091,48 @@ 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) + } + + 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/language-server/agenticChat/tools/chatDb/util.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts index 8f3f46b9f4..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 @@ -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 = 30 * 60 * 1000 // 30 minutes in milliseconds + + return cacheAge < CACHE_TTL +} + /** * Converts Message to codewhisperer-streaming ChatMessage */ 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 d0fe8ad9cc..027e349cf5 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 @@ -302,7 +302,7 @@ export class McpManager { cfg: MCPServerConfig, authIntent: AuthIntent = AuthIntent.Silent ): Promise { - const DEFAULT_SERVER_INIT_TIMEOUT_MS = 60_000 + const DEFAULT_SERVER_INIT_TIMEOUT_MS = 120_000 this.setState(serverName, McpServerStatus.INITIALIZING, 0) try { @@ -373,7 +373,7 @@ export class McpManager { } if (needsOAuth) { - OAuthClient.initialize(this.features.workspace, this.features.logging) + OAuthClient.initialize(this.features.workspace, this.features.logging, this.features.lsp) try { const bearer = await OAuthClient.getValidAccessToken(base, { interactive: authIntent === AuthIntent.Interactive, @@ -382,7 +382,7 @@ export class McpManager { 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.`, + `Server '${serverName}' requires OAuth. Click on Save to reauthenticate.`, 'MCPServerAuthFailed' ) } 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 index 08f8f1dd5c..ea711319a5 100644 --- 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 @@ -19,6 +19,12 @@ const fakeLogger = { error: () => {}, } +const fakeLsp = { + window: { + showDocument: sinon.stub().resolves({ success: true }), + }, +} as any + const fakeWorkspace = { fs: { exists: async (_path: string) => false, @@ -93,9 +99,10 @@ describe('OAuthClient getValidAccessToken()', () => { beforeEach(() => { sinon.restore() - OAuthClient.initialize(fakeWorkspace, fakeLogger as any) + OAuthClient.initialize(fakeWorkspace, fakeLogger as any, fakeLsp) sinon.stub(OAuthClient as any, 'computeKey').returns('testkey') stubHttpServer() + ;(fakeLsp.window.showDocument as sinon.SinonStub).resetHistory() }) afterEach(() => sinon.restore()) @@ -117,6 +124,6 @@ describe('OAuthClient getValidAccessToken()', () => { interactive: true, }) expect(token).to.equal('cached_access') - expect((http.createServer as any).calledOnce).to.be.true + expect((fakeLsp.window.showDocument as sinon.SinonStub).called).to.be.false }) }) 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 index 1e9c8745d3..2e207c449e 100644 --- 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 @@ -10,7 +10,7 @@ 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' +import { Logger, Workspace, Lsp } from '@aws/language-server-runtimes/server-interface' interface Token { access_token: string @@ -35,10 +35,12 @@ interface Registration { export class OAuthClient { private static logger: Logger private static workspace: Workspace + private static lsp: Lsp - public static initialize(ws: Workspace, logger: Logger): void { + public static initialize(ws: Workspace, logger: Logger, lsp: Lsp): void { this.workspace = ws this.logger = logger + this.lsp = lsp } /** @@ -95,10 +97,11 @@ export class OAuthClient { const savedReg = await this.read(regPath) if (savedReg) { const port = Number(new URL(savedReg.redirect_uri).port) + const normalized = `http://127.0.0.1:${port}` server = http.createServer() try { - await this.listen(server, port) - redirectUri = savedReg.redirect_uri + await this.listen(server, port, '127.0.0.1') + redirectUri = normalized this.logger.info(`OAuth: reusing redirect URI ${redirectUri}`) } catch (e: any) { if (e.code === 'EADDRINUSE') { @@ -182,9 +185,9 @@ export class OAuthClient { /** 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) + await this.listen(server, 0, '127.0.0.1') const port = (server.address() as any).port as number - return { server, redirectUri: `http://localhost:${port}` } + return { server, redirectUri: `http://127.0.0.1:${port}` } } /** Discover OAuth endpoints by HEAD/WWW‑Authenticate, well‑known, or fallback */ @@ -334,7 +337,7 @@ export class OAuthClient { redirectUri: string, server: http.Server ): Promise { - const DEFAULT_PKCE_TIMEOUT_MS = 20_000 + const DEFAULT_PKCE_TIMEOUT_MS = 90_000 // a) generate PKCE params const verifier = this.b64url(crypto.randomBytes(32)) const challenge = this.b64url(crypto.createHash('sha256').update(verifier).digest()) @@ -353,17 +356,7 @@ export class OAuthClient { 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() + await this.lsp.window.showDocument({ uri: authz.toString(), external: true }) // c) wait for code on our loopback const waitForFlow = new Promise<{ code: string; rxState: string; err?: string; errDesc?: string }>(resolve => { 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 3509cd1730..f6f735da09 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 @@ -11,6 +11,7 @@ import * as path from 'path' import { expect } from 'chai' import { CancellationError } from '@aws/lsp-core' import * as JSZip from 'jszip' +import { Origin } from '@amzn/codewhisperer-streaming' describe('CodeReview', () => { let sandbox: sinon.SinonSandbox @@ -103,6 +104,7 @@ describe('CodeReview', () => { folderLevelArtifacts: [], ruleArtifacts: [], scopeOfReview: FULL_REVIEW, + modelId: 'claude-4-sonnet', } }) @@ -143,6 +145,61 @@ describe('CodeReview', () => { expect(result.output.kind).to.equal('json') }) + it('should execute successfully and pass languageModelId and clientType to startCodeAnalysis', async () => { + const inputWithModelId = { + ...validInput, + modelId: 'test-model-789', + } + + // Setup mocks for successful execution + mockCodeWhispererClient.createUploadUrl.resolves({ + uploadUrl: 'https://upload.com', + uploadId: 'upload-123', + requestHeaders: {}, + }) + + mockCodeWhispererClient.startCodeAnalysis.resolves({ + jobId: 'job-123', + status: 'Pending', + }) + + mockCodeWhispererClient.getCodeAnalysis.resolves({ + status: 'Completed', + }) + + mockCodeWhispererClient.listCodeAnalysisFindings.resolves({ + codeAnalysisFindings: '[]', + nextToken: undefined, + }) + + sandbox.stub(CodeReviewUtils, 'uploadFileToPresignedUrl').resolves() + sandbox.stub(codeReview as any, 'prepareFilesAndFoldersForUpload').resolves({ + zipBuffer: Buffer.from('test'), + md5Hash: 'hash123', + isCodeDiffPresent: false, + programmingLanguages: new Set(['javascript']), + }) + sandbox.stub(codeReview as any, 'parseFindings').returns([]) + + const result = await codeReview.execute(inputWithModelId, context) + + expect(result.output.success).to.be.true + expect(result.output.kind).to.equal('json') + + // Verify that startCodeAnalysis was called with the correct parameters + expect(mockCodeWhispererClient.startCodeAnalysis.calledOnce).to.be.true + const startAnalysisCall = mockCodeWhispererClient.startCodeAnalysis.getCall(0) + const callArgs = startAnalysisCall.args[0] + + expect(callArgs).to.have.property('languageModelId', 'test-model-789') + expect(callArgs).to.have.property('clientType', Origin.IDE) + expect(callArgs).to.have.property('artifacts') + expect(callArgs).to.have.property('programmingLanguage') + expect(callArgs).to.have.property('clientToken') + expect(callArgs).to.have.property('codeScanName') + expect(callArgs).to.have.property('scope', 'AGENTIC') + }) + it('should handle missing client error', async () => { context.codeWhispererClient = undefined @@ -160,6 +217,7 @@ describe('CodeReview', () => { folderLevelArtifacts: [], ruleArtifacts: [], scopeOfReview: FULL_REVIEW, + modelId: 'claude-4-sonnet', } try { @@ -279,6 +337,7 @@ describe('CodeReview', () => { folderLevelArtifacts: [], ruleArtifacts: [], scopeOfReview: FULL_REVIEW, + modelId: 'claude-4-sonnet', } const context = { @@ -303,6 +362,7 @@ describe('CodeReview', () => { folderLevelArtifacts: [{ path: '/test/folder' }], ruleArtifacts: [], scopeOfReview: CODE_DIFF_REVIEW, + modelId: 'claude-4-sonnet', } const context = { @@ -614,6 +674,7 @@ describe('CodeReview', () => { folderLevelArtifacts: [], ruleArtifacts: [], scopeOfReview: FULL_REVIEW, + modelId: 'claude-4-sonnet', } // Make prepareFilesAndFoldersForUpload throw an error 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 b1ef4c3e61..ece6a73486 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 @@ -31,6 +31,7 @@ import { SuccessMetricName, } from './codeReviewTypes' import { CancellationError } from '@aws/lsp-core' +import { Origin } from '@amzn/codewhisperer-streaming' export class CodeReview { private static readonly CUSTOMER_CODE_BASE_PATH = 'customerCodeBaseFolder' @@ -158,6 +159,7 @@ export class CodeReview { const fileArtifacts = validatedInput.fileLevelArtifacts || [] const folderArtifacts = validatedInput.folderLevelArtifacts || [] const ruleArtifacts = validatedInput.ruleArtifacts || [] + const modelId = validatedInput.modelId if (fileArtifacts.length === 0 && folderArtifacts.length === 0) { CodeReviewUtils.emitMetric( @@ -182,7 +184,7 @@ export class CodeReview { const programmingLanguage = 'java' const scanName = 'Standard-' + randomUUID() - this.logging.info(`Agentic scan name: ${scanName}`) + this.logging.info(`Agentic scan name: ${scanName} selectedModel: ${modelId}`) return { fileArtifacts, @@ -192,6 +194,7 @@ export class CodeReview { programmingLanguage, scanName, ruleArtifacts, + modelId, } } @@ -274,6 +277,8 @@ export class CodeReview { codeScanName: setup.scanName, scope: CodeReview.SCAN_SCOPE, codeDiffMetadata: uploadResult.isCodeDiffPresent ? { codeDiffPath: '/code_artifact/codeDiff/' } : undefined, + languageModelId: setup.modelId, + clientType: Origin.IDE, }) if (!createResponse.jobId) { @@ -293,6 +298,7 @@ export class CodeReview { customRules: setup.ruleArtifacts.length, programmingLanguages: Array.from(uploadResult.programmingLanguages), scope: setup.isFullReviewRequest ? FULL_REVIEW : CODE_DIFF_REVIEW, + modelId: setup.modelId, }, }, this.logging, @@ -349,6 +355,7 @@ export class CodeReview { programmingLanguages: Array.from(uploadResult.programmingLanguages), scope: setup.isFullReviewRequest ? FULL_REVIEW : CODE_DIFF_REVIEW, status: status, + modelId: setup.modelId, }, }, this.logging, @@ -382,6 +389,7 @@ export class CodeReview { programmingLanguages: Array.from(uploadResult.programmingLanguages), scope: setup.isFullReviewRequest ? FULL_REVIEW : CODE_DIFF_REVIEW, status: status, + modelId: setup.modelId, }, }, this.logging, @@ -426,6 +434,7 @@ export class CodeReview { programmingLanguages: Array.from(uploadResult.programmingLanguages), scope: setup.isFullReviewRequest ? FULL_REVIEW : CODE_DIFF_REVIEW, latency: Date.now() - this.toolStartTime, + modelId: setup.modelId, }, }, this.logging, @@ -439,9 +448,27 @@ export class CodeReview { ) this.logging.info('Findings count grouped by file') - aggregatedCodeScanIssueList.forEach(item => + aggregatedCodeScanIssueList.forEach(item => { this.logging.info(`File path - ${item.filePath} Findings count - ${item.issues.length}`) - ) + item.issues.forEach(issue => + CodeReviewUtils.emitMetric( + { + reason: SuccessMetricName.IssuesDetected, + result: 'Succeeded', + metadata: { + codewhispererCodeScanJobId: jobId, + credentialStartUrl: this.credentialsProvider.getConnectionMetadata()?.sso?.startUrl, + findingId: issue.findingId, + detectorId: issue.detectorId, + ruleId: issue.ruleId, + autoDetected: false, + }, + }, + this.logging, + this.telemetry + ) + ) + }) return { codeReviewId: jobId, diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewSchemas.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewSchemas.ts index d734412066..e805145244 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewSchemas.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewSchemas.ts @@ -113,6 +113,7 @@ export const Z_CODE_REVIEW_INPUT_SCHEMA = z.object({ }) ) .optional(), + modelId: z.string(), }) /** diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewTypes.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewTypes.ts index 15aa32a5aa..c684cfecd3 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewTypes.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewTypes.ts @@ -10,6 +10,7 @@ export enum FailedMetricName { } export enum SuccessMetricName { CodeScanSuccess = 'codeScanSuccess', + IssuesDetected = 'issuesDetected', } export type ValidateInputAndSetupResult = { @@ -20,6 +21,7 @@ export type ValidateInputAndSetupResult = { programmingLanguage: string scanName: string ruleArtifacts: RuleArtifacts + modelId?: string } export type PrepareAndUploadArtifactsResult = { 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 e29c58fff4..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/language-server/agenticChat/utils/fileModificationMetrics.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/fileModificationMetrics.test.ts new file mode 100644 index 0000000000..900b569e7e --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/fileModificationMetrics.test.ts @@ -0,0 +1,139 @@ +import { calculateModifiedLines } from './fileModificationMetrics' +import { ToolUse } from '@amzn/codewhisperer-streaming' +import { FS_WRITE, FS_REPLACE } from '../constants/toolConstants' +import * as assert from 'assert' + +describe('calculateModifiedLines', () => { + describe('FS_WRITE', () => { + it('should count lines for create command', () => { + const toolUse: ToolUse = { + toolUseId: 'test-1', + name: FS_WRITE, + input: { + command: 'create', + path: '/test/file.txt', + fileText: 'line1\nline2\nline3', + }, + } + const afterContent = 'line1\nline2\nline3' + + assert.strictEqual(calculateModifiedLines(toolUse, afterContent), 3) + }) + + it('should count lines for append command', () => { + const toolUse: ToolUse = { + toolUseId: 'test-2', + name: FS_WRITE, + input: { + command: 'append', + path: '/test/file.txt', + fileText: 'line4\nline5', + }, + } + + assert.strictEqual(calculateModifiedLines(toolUse), 2) + }) + + it('should handle empty content', () => { + const toolUse: ToolUse = { + toolUseId: 'test-3', + name: FS_WRITE, + input: { + command: 'create', + path: '/test/file.txt', + fileText: '', + }, + } + + assert.strictEqual(calculateModifiedLines(toolUse, ''), 0) + }) + }) + + describe('FS_REPLACE', () => { + it('should count replaced lines correctly (double counting)', () => { + const toolUse: ToolUse = { + toolUseId: 'test-4', + name: FS_REPLACE, + input: { + path: '/test/file.txt', + diffs: [ + { + oldStr: 'old line 1\nold line 2\nold line 3', + newStr: 'new line 1\nnew line 2\nnew line 3', + }, + ], + }, + } + + assert.strictEqual(calculateModifiedLines(toolUse), 6) + }) + + it('should count pure deletions', () => { + const toolUse: ToolUse = { + toolUseId: 'test-5', + name: FS_REPLACE, + input: { + path: '/test/file.txt', + diffs: [ + { + oldStr: 'line to delete 1\nline to delete 2', + newStr: '', + }, + ], + }, + } + + assert.strictEqual(calculateModifiedLines(toolUse), 2) + }) + + it('should count pure insertions', () => { + const toolUse: ToolUse = { + toolUseId: 'test-6', + name: FS_REPLACE, + input: { + path: '/test/file.txt', + diffs: [ + { + oldStr: '', + newStr: 'new line 1\nnew line 2', + }, + ], + }, + } + + assert.strictEqual(calculateModifiedLines(toolUse), 2) + }) + + it('should handle multiple diffs', () => { + const toolUse: ToolUse = { + toolUseId: 'test-7', + name: FS_REPLACE, + input: { + path: '/test/file.txt', + diffs: [ + { + oldStr: 'old line 1', + newStr: 'new line 1', + }, + { + oldStr: 'delete this line', + newStr: '', + }, + ], + }, + } + + assert.strictEqual(calculateModifiedLines(toolUse), 3) + }) + }) + + it('should return 0 for unknown tools', () => { + const toolUse: ToolUse = { + toolUseId: 'test-8', + name: 'unknownTool', + input: {}, + } + + assert.strictEqual(calculateModifiedLines(toolUse), 0) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/fileModificationMetrics.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/fileModificationMetrics.ts new file mode 100644 index 0000000000..361c886607 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/fileModificationMetrics.ts @@ -0,0 +1,58 @@ +import { ToolUse } from '@amzn/codewhisperer-streaming' +import { diffLines } from 'diff' +import { FsWriteParams } from '../tools/fsWrite' +import { FsReplaceParams } from '../tools/fsReplace' +import { FS_WRITE, FS_REPLACE } from '../constants/toolConstants' + +/** + * Counts the number of lines in text, handling different line endings + * @param text The text to count lines in + * @returns The number of lines + */ +function countLines(text?: string): number { + if (!text) return 0 + const parts = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n') + return parts.length && parts[parts.length - 1] === '' ? parts.length - 1 : parts.length +} + +/** + * Calculates the actual lines modified by analyzing file modification tools. + * @param toolUse The tool use object + * @param afterContent The content after the tool execution (for FS_WRITE create operations) + * @returns The total number of lines modified (added + removed) + */ +export function calculateModifiedLines(toolUse: ToolUse, afterContent?: string): number { + if (toolUse.name === FS_WRITE) { + const input = toolUse.input as unknown as FsWriteParams + + if (input.command === 'create') { + return countLines(afterContent ?? '') + } else if (input.command === 'append') { + return countLines(input.fileText) + } + } + + if (toolUse.name === FS_REPLACE) { + const input = toolUse.input as unknown as FsReplaceParams + let linesAdded = 0 + let linesRemoved = 0 + + for (const diff of input.diffs || []) { + const oldStr = diff.oldStr ?? '' + const newStr = diff.newStr ?? '' + + const changes = diffLines(oldStr, newStr) + + for (const change of changes) { + if (change.added) { + linesAdded += countLines(change.value) + } else if (change.removed) { + linesRemoved += countLines(change.value) + } + } + } + + return linesAdded + linesRemoved + } + return 0 +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts index 92c7eb2c33..13c198a949 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts @@ -78,10 +78,17 @@ export class ChatSessionService { public getDeferredToolExecution(messageId: string): DeferredHandler | undefined { return this.#deferredToolExecution[messageId] } + public setDeferredToolExecution(messageId: string, resolve: any, reject: any) { this.#deferredToolExecution[messageId] = { resolve, reject } } + public removeDeferredToolExecution(messageId: string) { + if (messageId in this.#deferredToolExecution) { + delete this.#deferredToolExecution[messageId] + } + } + public getAllDeferredCompactMessageIds(): string[] { return Object.keys(this.#deferredToolExecution).filter(messageId => messageId.endsWith('_compact')) } diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts index 8e9cb92624..797a1f640a 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts @@ -294,7 +294,12 @@ export class ChatTelemetryController { }) } - public emitAddMessageMetric(tabId: string, metric: Partial, result?: string) { + public emitAddMessageMetric( + tabId: string, + metric: Partial, + result?: string, + errorMessage?: string + ) { const conversationId = this.getConversationId(tabId) // Store the customization value associated with the message if (metric.cwsprChatMessageId && metric.codewhispererCustomizationArn) { @@ -349,6 +354,7 @@ export class ChatTelemetryController { requestIds: metric.requestIds, experimentName: metric.experimentName, userVariation: metric.userVariation, + errorMessage: errorMessage, } ) } @@ -421,10 +427,12 @@ export class ChatTelemetryController { public emitInteractWithMessageMetric( tabId: string, - metric: Omit + metric: Omit, + acceptedLineCount?: number ) { return this.#telemetryService.emitChatInteractWithMessage(metric, { conversationId: this.getConversationId(tabId), + acceptedLineCount, }) } diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.test.ts index 6f96c526de..31bc0a224a 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.test.ts @@ -158,7 +158,7 @@ describe('Auto Trigger', async () => { assert.strictEqual(getAutoTriggerType(createContentChange('line1\nline2')), undefined) }) }) - describe.skip('Right Context should trigger validation', () => { + describe('Right Context should trigger validation', () => { it('should not trigger when there is immediate right context in VSCode', () => { const params = createBasicParams({ fileContext: createBasicFileContext('console.', 'log()'), diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.ts index 9fb2efab8e..8990756dbf 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.ts @@ -177,7 +177,7 @@ type AutoTriggerParams = { char: string triggerType: string // Left as String intentionally to support future and unknown trigger types os: string - previousDecision: string + previousDecision: string | undefined ide: string lineNum: number } @@ -229,23 +229,27 @@ export const autoTrigger = ( const triggerTypeCoefficient = coefficients.triggerTypeCoefficient[triggerType] ?? 0 const osCoefficient = coefficients.osCoefficient[os] ?? 0 - let charCoefficient = coefficients.charCoefficient[char] ?? 0 - // this is a temporary change to lower the auto trigger frequency - if (ide === 'VSCODE') { - charCoefficient = 0 - } + + const charCoefficient = coefficients.charCoefficient[char] ?? 0 const keyWordCoefficient = coefficients.charCoefficient[keyword] ?? 0 const languageCoefficient = coefficients.languageCoefficient[fileContext.programmingLanguage.languageName] ?? 0 let previousDecisionCoefficient = 0 - if (previousDecision === 'Accept') { - previousDecisionCoefficient = coefficients.prevDecisionAcceptCoefficient - } else if (previousDecision === 'Reject') { - previousDecisionCoefficient = coefficients.prevDecisionRejectCoefficient - } else if (previousDecision === 'Discard' || previousDecision === 'Empty') { - previousDecisionCoefficient = coefficients.prevDecisionOtherCoefficient + switch (previousDecision) { + case 'Accept': + previousDecisionCoefficient = coefficients.prevDecisionAcceptCoefficient + break + case 'Reject': + previousDecisionCoefficient = coefficients.prevDecisionRejectCoefficient + break + case 'Discard': + case 'Empty': + previousDecisionCoefficient = coefficients.prevDecisionOtherCoefficient + break + default: + break } const ideCoefficient = coefficients.ideCoefficient[ide] ?? 0 @@ -279,11 +283,13 @@ export const autoTrigger = ( previousDecisionCoefficient + languageCoefficient + leftContextLengthCoefficient - const shouldTrigger = sigmoid(classifierResult) > TRIGGER_THRESHOLD + + const r = sigmoid(classifierResult) + const shouldTrigger = r > TRIGGER_THRESHOLD return { shouldTrigger, - classifierResult, + classifierResult: r, classifierThreshold: TRIGGER_THRESHOLD, } } 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 330cf54735..c869a534fd 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 @@ -239,7 +239,11 @@ export const CodewhispererServerFactory = : undefined const previousSession = completionSessionManager.getPreviousSession() - const previousDecision = previousSession?.getAggregatedUserTriggerDecision() ?? '' + // Only refer to decisions in the past 2 mins + const previousDecisionForClassifier = + previousSession && performance.now() - previousSession.decisionMadeTimestamp <= 2 * 60 * 1000 + ? previousSession.getAggregatedUserTriggerDecision() + : undefined let ideCategory: string | undefined = '' const initializeParams = lsp.getClientInitializeParams() if (initializeParams !== undefined) { @@ -280,7 +284,7 @@ export const CodewhispererServerFactory = char: triggerCharacters, // Add the character just inserted, if any, before the invication position ide: ideCategory ?? '', os: getNormalizeOsName(), - previousDecision, // The last decision by the user on the previous invocation + previousDecision: previousDecisionForClassifier, // The last decision by the user on the previous invocation triggerType: codewhispererAutoTriggerType, // The 2 trigger types currently influencing the Auto-Trigger are SpecialCharacter and Enter }, logging 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 index 193521cce9..550de06708 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts @@ -45,6 +45,8 @@ export class EditCompletionHandler { private hasDocumentChangedSinceInvocation: boolean = false private readonly streakTracker: StreakTracker + private isInProgress = false + constructor( readonly logging: Logging, readonly clientMetadata: InitializeParams, @@ -89,6 +91,11 @@ export class EditCompletionHandler { params: InlineCompletionWithReferencesParams, token: CancellationToken ): Promise { + if (this.isInProgress) { + this.logging.info(`editCompletionHandler is WIP, skip the request`) + return EMPTY_RESULT + } + // On every new completion request close current inflight session. const currentSession = this.sessionManager.getCurrentSession() if (currentSession && currentSession.state == 'REQUESTING' && !params.partialResultToken) { @@ -118,6 +125,9 @@ export class EditCompletionHandler { return EMPTY_RESULT } + // Not ideally to rely on a state, should improve it and simply make it a debounced API + this.isInProgress = true + if (params.partialResultToken && currentSession) { // Close ACTIVE session. We shouldn't record Discard trigger decision for trigger with nextToken. if (currentSession && currentSession.state === 'ACTIVE') { @@ -152,10 +162,12 @@ export class EditCompletionHandler { ) } catch (error) { return this.handleSuggestionsErrors(error as Error, currentSession) + } finally { + this.isInProgress = false } } - return new Promise(async resolve => { + return new Promise(async resolve => { this.debounceTimeout = setTimeout(async () => { try { this.isWaiting = true @@ -185,6 +197,8 @@ export class EditCompletionHandler { this.hasDocumentChangedSinceInvocation = false } }, EDIT_DEBOUNCE_INTERVAL_MS) + }).finally(() => { + this.isInProgress = false }) } 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 34dbb12538..2a4af79ff6 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 @@ -60,6 +60,13 @@ export class CodeWhispererSession { suggestions: CachedSuggestion[] = [] suggestionsAfterRightContextMerge: InlineCompletionItemWithReferences[] = [] suggestionsStates = new Map() + private _decisionTimestamp = 0 + get decisionMadeTimestamp() { + return this._decisionTimestamp + } + set decisionMadeTimestamp(time: number) { + this._decisionTimestamp = time + } acceptedSuggestionId?: string = undefined responseContext?: ResponseContext triggerType: CodewhispererTriggerType 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 c583815b6b..21c398d56a 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 @@ -145,7 +145,7 @@ describe('Telemetry', () => { }, } const EMPTY_RESULT = { items: [], sessionId: '' } - const classifierResult = getNormalizeOsName() !== 'Linux' ? 0.4114381148145918 : 0.46733811481459187 + const classifierResult = getNormalizeOsName() !== 'Linux' ? 0.6014326616203989 : 0.61475353067264 let features: TestFeatures let server: Server @@ -1251,7 +1251,7 @@ describe('Telemetry', () => { triggerType: 'AutoTrigger', autoTriggerType: 'SpecialCharacters', triggerCharacter: '(', - classifierResult: getNormalizeOsName() === 'Linux' ? 0.30173811481459184 : 0.2458381148145919, + classifierResult: getNormalizeOsName() === 'Linux' ? 0.5748673583477094 : 0.5611518554232429, classifierThreshold: 0.43, language: 'csharp', requestContext: { diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts index 5a919c9192..df144e72d6 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' import { ProfileStatusMonitor } from '../../language-server/agenticChat/tools/mcp/profileStatusMonitor' /** @@ -148,6 +149,10 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager< if (type === 'iam') { return } + + // Clear model cache when credentials are deleted + ChatDatabase.clearModelCache() + this.cancelActiveProfileChangeToken() this.resetCodewhispererService() diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts index 030f7f7ee4..984bb6da76 100644 --- a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts @@ -444,6 +444,8 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { "endpoint": ${this.codeWhispererEndpoint}, "predictionType": ${request.predictionTypes?.toString() ?? 'Not specified (COMPLETIONS)'}, "filename": ${request.fileContext.filename}, + "leftContextLength": ${request.fileContext.leftFileContent.length}, + rightContextLength: ${request.fileContext.rightFileContent.length}, "language": ${request.fileContext.programmingLanguage.languageName}, "supplementalContextCount": ${request.supplementalContexts?.length ?? 0}, "request.nextToken": ${request.nextToken}, @@ -615,6 +617,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 */ 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' diff --git a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.ts b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.ts index ca5bc40642..39e97a3b8c 100644 --- a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.ts +++ b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.ts @@ -71,12 +71,13 @@ export async function fetchSupplementalContextForSrc( const supplementalContextConfig = getSupplementalContextConfig(document.languageId) if (supplementalContextConfig === undefined) { - return supplementalContextConfig + return undefined } - //TODO: add logic for other strategies once available + if (supplementalContextConfig === 'codemap') { return await codemapContext(document, position, workspace, cancellationToken, openTabFiles) } + return { supplementalContextItems: [], strategy: 'Empty' } } @@ -255,10 +256,7 @@ function getInputChunk(document: TextDocument, cursorPosition: Position, chunkSi * @returns specifically returning undefined if the langueage is not supported, * otherwise true/false depending on if the language is fully supported or not belonging to the user group */ -function getSupplementalContextConfig( - languageId: TextDocument['languageId'], - _userGroup?: any -): SupplementalContextStrategy | undefined { +function getSupplementalContextConfig(languageId: TextDocument['languageId']): SupplementalContextStrategy | undefined { return isCrossFileSupported(languageId) ? 'codemap' : undefined } diff --git a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/supplementalContextUtil.ts b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/supplementalContextUtil.ts index 8a5658188b..8e0902452f 100644 --- a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/supplementalContextUtil.ts +++ b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/supplementalContextUtil.ts @@ -91,7 +91,24 @@ export async function fetchSupplementalContext( strategy: supplementalContextValue.strategy, } - return truncateSupplementalContext(resBeforeTruncation) + const r = truncateSupplementalContext(resBeforeTruncation) + + let logstr = `@@supplemental context@@ +\tisUtg: ${r.isUtg}, +\tisProcessTimeout: ${r.isProcessTimeout}, +\tcontents.length: ${r.contentsLength}, +\tlatency: ${r.latency}, +\tstrategy: ${r.strategy}, +` + r.supplementalContextItems.forEach((item, index) => { + logstr += `\tChunk [${index}th]:\n` + logstr += `\t\tPath: ${item.filePath}\n` + logstr += `\t\tLength: ${item.content.length}\n` + logstr += `\t\tScore: ${item.score}\n` + }) + logging.info(logstr) + + return r } else { return undefined } 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 04d1cf091c..d66ada7707 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts @@ -277,6 +277,7 @@ describe('TelemetryService', () => { addedIdeDiagnostics: undefined, removedIdeDiagnostics: undefined, streakLength: 0, + suggestionType: 'COMPLETIONS', }, }, optOutPreference: 'OPTIN', @@ -858,6 +859,7 @@ describe('TelemetryService', () => { cwsprChatPinnedFileContextCount: undefined, cwsprChatPinnedFolderContextCount: undefined, cwsprChatPinnedPromptContextCount: undefined, + errorMessage: undefined, }, }) }) diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts index 6f3ba52028..78182c076f 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts @@ -66,6 +66,7 @@ export class TelemetryService { [ChatInteractionType.Upvote]: 'UPVOTE', [ChatInteractionType.Downvote]: 'DOWNVOTE', [ChatInteractionType.ClickBodyLink]: 'CLICK_BODY_LINK', + [ChatInteractionType.AgenticCodeAccepted]: 'AGENTIC_CODE_ACCEPTED', } constructor( @@ -199,6 +200,7 @@ export class TelemetryService { removedIdeDiagnostics?: IdeDiagnostic[], streakLength?: number ) { + session.decisionMadeTimestamp = performance.now() if (this.enableTelemetryEventsToDestination) { const data: CodeWhispererUserTriggerDecisionEvent = { codewhispererSessionId: session.codewhispererSessionId || '', @@ -281,14 +283,17 @@ export class TelemetryService { addedIdeDiagnostics: addedIdeDiagnostics, removedIdeDiagnostics: removedIdeDiagnostics, streakLength: streakLength ?? 0, + suggestionType: isInlineEdit ? 'EDITS' : 'COMPLETIONS', } this.logging.info(`Invoking SendTelemetryEvent:UserTriggerDecisionEvent with: + "requestId": ${event.requestId} "suggestionState": ${event.suggestionState} "acceptedCharacterCount": ${event.acceptedCharacterCount} "addedCharacterCount": ${event.addedCharacterCount} "deletedCharacterCount": ${event.deletedCharacterCount} "streakLength": ${event.streakLength} - "firstCompletionDisplayLatency: ${event.recommendationLatencyMilliseconds}`) + "firstCompletionDisplayLatency: ${event.recommendationLatencyMilliseconds} + "suggestionType": ${event.suggestionType}`) return this.invokeSendTelemetryEvent({ userTriggerDecisionEvent: event, }) @@ -559,6 +564,7 @@ export class TelemetryService { requestIds?: string[] experimentName?: string userVariation?: string + errorMessage?: string }> ) { const timeBetweenChunks = params.timeBetweenChunks?.slice(0, 100) @@ -612,6 +618,7 @@ export class TelemetryService { requestIds: truncatedRequestIds, experimentName: additionalParams.experimentName, userVariation: additionalParams.userVariation, + errorMessage: additionalParams.errorMessage, }, }) } diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts index bed82c2939..5bf9fea185 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts @@ -429,6 +429,7 @@ export enum ChatInteractionType { Upvote = 'upvote', Downvote = 'downvote', ClickBodyLink = 'clickBodyLink', + AgenticCodeAccepted = 'agenticCodeAccepted', } export enum ChatHistoryActionType { diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.ts b/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.ts index 49020a8822..e682263f7e 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.ts @@ -103,6 +103,8 @@ export const makeUserContextObject = ( product: product, clientId: initializeParams.initializationOptions?.aws?.clientInfo?.clientId, ideVersion: `ide=${ideVersion};plugin=${pluginVersion};lsp=${lspVersion}`, + pluginVersion: pluginVersion, + lspVersion: lspVersion, } if (userContext.ideCategory === 'UNKNOWN' || userContext.operatingSystem === 'UNKNOWN') {