diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 61dc88f5e1..c3df1b76e7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,8 +1,8 @@ { - "chat-client": "0.1.30", + "chat-client": "0.1.31", "core/aws-lsp-core": "0.0.13", "server/aws-lsp-antlr4": "0.1.17", - "server/aws-lsp-codewhisperer": "0.0.71", + "server/aws-lsp-codewhisperer": "0.0.72", "server/aws-lsp-json": "0.1.17", "server/aws-lsp-partiql": "0.0.16", "server/aws-lsp-yaml": "0.1.17" diff --git a/app/aws-lsp-antlr4-runtimes/package.json b/app/aws-lsp-antlr4-runtimes/package.json index e283033afb..bf7cf47bf1 100644 --- a/app/aws-lsp-antlr4-runtimes/package.json +++ b/app/aws-lsp-antlr4-runtimes/package.json @@ -12,7 +12,7 @@ "webpack": "webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-antlr4": "*", "antlr4-c3": "^3.4.1", "antlr4ng": "^3.0.4" diff --git a/app/aws-lsp-buildspec-runtimes/package.json b/app/aws-lsp-buildspec-runtimes/package.json index 0fa2217def..b9c36946b2 100644 --- a/app/aws-lsp-buildspec-runtimes/package.json +++ b/app/aws-lsp-buildspec-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-buildspec": "^0.0.1" } } diff --git a/app/aws-lsp-cloudformation-runtimes/package.json b/app/aws-lsp-cloudformation-runtimes/package.json index 336e9b48ae..a88386db4e 100644 --- a/app/aws-lsp-cloudformation-runtimes/package.json +++ b/app/aws-lsp-cloudformation-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-cloudformation": "^0.0.1" } } diff --git a/app/aws-lsp-codewhisperer-runtimes/README.md b/app/aws-lsp-codewhisperer-runtimes/README.md index 977d514bd6..71fbcf0456 100644 --- a/app/aws-lsp-codewhisperer-runtimes/README.md +++ b/app/aws-lsp-codewhisperer-runtimes/README.md @@ -76,6 +76,17 @@ The server is managed via scripts/dev-server.js, which ensures: **NOTE**: Tests are currently disabled for Windows as we currently face issues with automatically shutting down devserver and cleaning resources after tests are executed. +## Binary Dependencies + +### registry.node +The file `_bundle-assets/registry-js/win32-x64/registry.node` is a precompiled binary downloaded from the [registry-js](https://github.com/desktop/registry-js) project. + +- **Current version**: v1.16.1 (released May 21, 2024) +- **Source**: https://github.com/desktop/registry-js/releases +- **Purpose**: Provides Windows registry access functionality + +**To update**: Download the latest `registry.node` binary for win32-x64 from the registry-js releases page and replace the existing file. + #### Tests configuration - Test settings are defined in `wdio.conf.ts` - The actual test implementation is in the `test/e2e` folder diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/registry-js/win32-x64/registry.node b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/registry-js/win32-x64/registry.node new file mode 100644 index 0000000000..d7b5091a2c Binary files /dev/null and b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/registry-js/win32-x64/registry.node differ diff --git a/app/aws-lsp-codewhisperer-runtimes/package.json b/app/aws-lsp-codewhisperer-runtimes/package.json index 6a1721382c..487d2c5b1a 100644 --- a/app/aws-lsp-codewhisperer-runtimes/package.json +++ b/app/aws-lsp-codewhisperer-runtimes/package.json @@ -10,10 +10,11 @@ "package:prod": "npm run compile && cross-env NODE_OPTIONS=--max_old_space_size=8172 npm run webpack:prod", "webpack": "webpack", "webpack:prod": "webpack --config webpack.config.prod.js", + "copy:native-deps:agent-standalone": "copyfiles -f _bundle-assets/registry-js/win32-x64/registry.node build/private/bundle/agent-standalone", "copy:resources:agent-standalone": "copyfiles -f --error ../../node_modules/@aws/lsp-identity/src/sso/authorizationCodePkce/resources/**/* build/private/bundle/agent-standalone/resources", "generate:node-assets": "./scripts/download-node.sh && ts-node src/scripts/copy-node-assets.ts", "generate:build-archive": "./scripts/package.sh", - "ci:generate:agent-standalone": "npm run package:prod && npm run copy:resources:agent-standalone && npm run generate:node-assets && npm run generate:build-archive", + "ci:generate:agent-standalone": "npm run package:prod && npm run copy:native-deps:agent-standalone && npm run copy:resources:agent-standalone && npm run generate:node-assets && npm run generate:build-archive", "ci:generate:manifest": "ts-node scripts/create-repo-manifest.ts", "start": "cross-env NODE_OPTIONS=--max_old_space_size=8172 node scripts/dev-server.js start", "stop-dev-server": "node scripts/dev-server.js stop", @@ -22,7 +23,7 @@ "local-build": "node scripts/local-build.js" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-codewhisperer": "*", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", diff --git a/app/aws-lsp-codewhisperer-runtimes/src/version.json b/app/aws-lsp-codewhisperer-runtimes/src/version.json index 002b19fa71..2381334b11 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.24.0" + "agenticChat": "1.26.0" } diff --git a/app/aws-lsp-identity-runtimes/package.json b/app/aws-lsp-identity-runtimes/package.json index b4971eae87..46abf7d958 100644 --- a/app/aws-lsp-identity-runtimes/package.json +++ b/app/aws-lsp-identity-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-identity": "^0.0.1" } } diff --git a/app/aws-lsp-json-runtimes/package.json b/app/aws-lsp-json-runtimes/package.json index 9fa7b7e958..24ae3535ac 100644 --- a/app/aws-lsp-json-runtimes/package.json +++ b/app/aws-lsp-json-runtimes/package.json @@ -11,7 +11,7 @@ "webpack": "webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-json": "*" }, "devDependencies": { diff --git a/app/aws-lsp-notification-runtimes/package.json b/app/aws-lsp-notification-runtimes/package.json index a355a967bf..1e7641e2a8 100644 --- a/app/aws-lsp-notification-runtimes/package.json +++ b/app/aws-lsp-notification-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-notification": "^0.0.1" } } diff --git a/app/aws-lsp-s3-runtimes/package.json b/app/aws-lsp-s3-runtimes/package.json index 1c15a9af5e..ad84f62776 100644 --- a/app/aws-lsp-s3-runtimes/package.json +++ b/app/aws-lsp-s3-runtimes/package.json @@ -10,7 +10,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-s3": "^0.0.1" } } diff --git a/app/aws-lsp-yaml-json-webworker/package.json b/app/aws-lsp-yaml-json-webworker/package.json index bfb5fa6277..7079d1fa3b 100644 --- a/app/aws-lsp-yaml-json-webworker/package.json +++ b/app/aws-lsp-yaml-json-webworker/package.json @@ -11,7 +11,7 @@ "serve:webpack": "NODE_ENV=development webpack serve" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*" }, diff --git a/app/aws-lsp-yaml-runtimes/package.json b/app/aws-lsp-yaml-runtimes/package.json index 86638b8ffc..a59f919477 100644 --- a/app/aws-lsp-yaml-runtimes/package.json +++ b/app/aws-lsp-yaml-runtimes/package.json @@ -11,7 +11,7 @@ "webpack": "webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-yaml": "*" }, "devDependencies": { diff --git a/app/hello-world-lsp-runtimes/package.json b/app/hello-world-lsp-runtimes/package.json index 2ccccc694b..54018d89d0 100644 --- a/app/hello-world-lsp-runtimes/package.json +++ b/app/hello-world-lsp-runtimes/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@aws/hello-world-lsp": "^0.0.1", - "@aws/language-server-runtimes": "^0.2.121" + "@aws/language-server-runtimes": "^0.2.123" }, "devDependencies": { "@types/chai": "^4.3.5", diff --git a/chat-client/CHANGELOG.md b/chat-client/CHANGELOG.md index 41178f1cb7..aa178a18e6 100644 --- a/chat-client/CHANGELOG.md +++ b/chat-client/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [0.1.31](https://github.com/aws/language-servers/compare/chat-client/v0.1.30...chat-client/v0.1.31) (2025-08-06) + + +### Features + +* **amazonq:** add two more tips for the did you know section ([#2063](https://github.com/aws/language-servers/issues/2063)) ([9949c6d](https://github.com/aws/language-servers/commit/9949c6dd81c56b5ea82563310da2eaee4d00a059)) +* **amazonq:** enable sonnet 4 for fra region ([#2069](https://github.com/aws/language-servers/issues/2069)) ([3a4b8df](https://github.com/aws/language-servers/commit/3a4b8df981b2c3b0532360a11472169fffec7924)) + + +### Bug Fixes + +* **amazonq:** fix to add disable/enable feature back to mcp servers ([#2052](https://github.com/aws/language-servers/issues/2052)) ([c03e017](https://github.com/aws/language-servers/commit/c03e017b9ccbbbb9c80a3c3afd5da38a50bd6cff)) + ## [0.1.30](https://github.com/aws/language-servers/compare/chat-client/v0.1.29...chat-client/v0.1.30) (2025-08-04) diff --git a/chat-client/package.json b/chat-client/package.json index 9eb33a6a11..ef3ba87421 100644 --- a/chat-client/package.json +++ b/chat-client/package.json @@ -1,6 +1,6 @@ { "name": "@aws/chat-client", - "version": "0.1.30", + "version": "0.1.31", "description": "AWS Chat Client", "main": "out/index.js", "repository": { @@ -25,7 +25,7 @@ }, "dependencies": { "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/language-server-runtimes-types": "^0.1.50", "@aws/mynah-ui": "^4.36.2" }, diff --git a/chat-client/src/client/mcpMynahUi.test.ts b/chat-client/src/client/mcpMynahUi.test.ts index 3e1ca73f33..9d1dec5407 100644 --- a/chat-client/src/client/mcpMynahUi.test.ts +++ b/chat-client/src/client/mcpMynahUi.test.ts @@ -577,8 +577,9 @@ describe('McpMynahUi', () => { assert.strictEqual(detailedList.header.actions[0].id, 'mcp-details-menu') // Verify the mcp-details-menu items - assert.strictEqual(detailedList.header.actions[0].items.length, 1) - assert.strictEqual(detailedList.header.actions[0].items[0].id, 'mcp-delete-server') + assert.strictEqual(detailedList.header.actions[0].items.length, 2) + assert.strictEqual(detailedList.header.actions[0].items[0].id, 'mcp-disable-server') + assert.strictEqual(detailedList.header.actions[0].items[1].id, 'mcp-delete-server') assert.strictEqual(detailedList.filterOptions.length, 1) assert.strictEqual(detailedList.filterOptions[0].id, 'permission') diff --git a/chat-client/src/client/mcpMynahUi.ts b/chat-client/src/client/mcpMynahUi.ts index 3e64336876..f9216b8168 100644 --- a/chat-client/src/client/mcpMynahUi.ts +++ b/chat-client/src/client/mcpMynahUi.ts @@ -167,11 +167,11 @@ export class McpMynahUi { id: MCP_IDS.DETAILS_MENU, icon: toMynahIcon('ellipsis'), items: [ - // { - // id: MCP_IDS.DISABLE_SERVER, - // text: `Disable MCP server`, - // data: { serverName }, - // }, + { + id: MCP_IDS.DISABLE_SERVER, + text: `Disable MCP server`, + data: { serverName }, + }, { id: MCP_IDS.DELETE_SERVER, confirmation: { @@ -220,10 +220,10 @@ export class McpMynahUi { ...(action.id === MCP_IDS.DETAILS_MENU ? { items: [ - // { - // id: MCP_IDS.DISABLE_SERVER, - // text: `Disable MCP server`, - // }, + { + id: MCP_IDS.DISABLE_SERVER, + text: `Disable MCP server`, + }, { id: MCP_IDS.DELETE_SERVER, confirmation: { diff --git a/chat-client/src/client/tabs/tabFactory.ts b/chat-client/src/client/tabs/tabFactory.ts index af1af21e7d..3a6012471a 100644 --- a/chat-client/src/client/tabs/tabFactory.ts +++ b/chat-client/src/client/tabs/tabFactory.ts @@ -187,6 +187,8 @@ Select code & ask me to explain, debug or optimize it, or type \`/\` for quick a 'MCP is available in Amazon Q!', 'Pinned context is always included in future chat messages', 'Create and add Saved Prompts using the @ context menu', + 'Compact your conversation with /compact', + 'Ask Q to review your code and see results in the code issues panel!', ] const randomIndex = Math.floor(Math.random() * hints.length) diff --git a/chat-client/src/client/texts/modelSelection.test.ts b/chat-client/src/client/texts/modelSelection.test.ts index 762eb6f818..c36dfad976 100644 --- a/chat-client/src/client/texts/modelSelection.test.ts +++ b/chat-client/src/client/texts/modelSelection.test.ts @@ -39,18 +39,15 @@ describe('modelSelection', () => { ) }) - it('should provide limited models for eu-central-1 region', () => { + 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, 1, 'should have 1 option') + 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 not include Claude Sonnet 4' - ) + 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' diff --git a/chat-client/src/client/texts/modelSelection.ts b/chat-client/src/client/texts/modelSelection.ts index 28fe969bc9..18df833419 100644 --- a/chat-client/src/client/texts/modelSelection.ts +++ b/chat-client/src/client/texts/modelSelection.ts @@ -37,10 +37,7 @@ const modelSelection: ChatItemFormItem = { */ export const modelSelectionForRegion: Record = { 'us-east-1': modelSelection, - 'eu-central-1': { - ...modelSelection, - options: modelOptions.filter(option => option.value !== BedrockModel.CLAUDE_SONNET_4_20250514_V1_0), - }, + 'eu-central-1': modelSelection, } export const getModelSelectionChatItem = (modelName: string): ChatItem => ({ diff --git a/client/vscode/package.json b/client/vscode/package.json index 4b8c2fdb4c..975a395d66 100644 --- a/client/vscode/package.json +++ b/client/vscode/package.json @@ -352,7 +352,7 @@ "@aws-sdk/credential-providers": "^3.731.1", "@aws-sdk/types": "^3.734.0", "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@types/uuid": "^9.0.8", "@types/vscode": "^1.98.0", "jose": "^5.2.4", diff --git a/core/aws-lsp-core/package.json b/core/aws-lsp-core/package.json index 4c033448eb..aeff582d34 100644 --- a/core/aws-lsp-core/package.json +++ b/core/aws-lsp-core/package.json @@ -28,7 +28,7 @@ "prepack": "shx cp ../../LICENSE ../../NOTICE ../../SECURITY.md ." }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@gerhobbelt/gitignore-parser": "^0.2.0-9", "cross-spawn": "7.0.6", "jose": "^5.2.4", diff --git a/integration-tests/q-agentic-chat-server/package.json b/integration-tests/q-agentic-chat-server/package.json index 26202df0c1..5431142f5c 100644 --- a/integration-tests/q-agentic-chat-server/package.json +++ b/integration-tests/q-agentic-chat-server/package.json @@ -9,7 +9,7 @@ "test-integ": "npm run compile && mocha --timeout 30000 \"./out/**/*.test.js\" --retries 2" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "*" }, "devDependencies": { diff --git a/package-lock.json b/package-lock.json index 1d8f367c34..9bcaeb0f84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "name": "@aws/lsp-antlr4-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-antlr4": "*", "antlr4-c3": "^3.4.1", "antlr4ng": "^3.0.4" @@ -71,7 +71,7 @@ "name": "@aws/lsp-buildspec-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-buildspec": "^0.0.1" } }, @@ -79,7 +79,7 @@ "name": "@aws/lsp-cloudformation-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-cloudformation": "^0.0.1" } }, @@ -87,7 +87,7 @@ "name": "@aws/lsp-codewhisperer-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-codewhisperer": "*", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", @@ -120,7 +120,7 @@ "name": "@aws/lsp-identity-runtimes", "version": "0.1.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-identity": "^0.0.1" } }, @@ -128,7 +128,7 @@ "name": "@aws/lsp-json-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-json": "*" }, "devDependencies": { @@ -148,7 +148,7 @@ "name": "@aws/lsp-notification-runtimes", "version": "0.1.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-notification": "^0.0.1" } }, @@ -181,7 +181,7 @@ "name": "@aws/lsp-s3-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-s3": "^0.0.1" }, "bin": { @@ -192,7 +192,7 @@ "name": "@aws/lsp-yaml-json-webworker", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*" }, @@ -212,7 +212,7 @@ "name": "@aws/lsp-yaml-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-yaml": "*" }, "devDependencies": { @@ -234,7 +234,7 @@ "version": "0.0.1", "dependencies": { "@aws/hello-world-lsp": "^0.0.1", - "@aws/language-server-runtimes": "^0.2.121" + "@aws/language-server-runtimes": "^0.2.123" }, "devDependencies": { "@types/chai": "^4.3.5", @@ -251,11 +251,11 @@ }, "chat-client": { "name": "@aws/chat-client", - "version": "0.1.30", + "version": "0.1.31", "license": "Apache-2.0", "dependencies": { "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/language-server-runtimes-types": "^0.1.50", "@aws/mynah-ui": "^4.36.2" }, @@ -280,7 +280,7 @@ "@aws-sdk/credential-providers": "^3.731.1", "@aws-sdk/types": "^3.734.0", "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@types/uuid": "^9.0.8", "@types/vscode": "^1.98.0", "jose": "^5.2.4", @@ -296,7 +296,7 @@ "version": "0.0.13", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@gerhobbelt/gitignore-parser": "^0.2.0-9", "cross-spawn": "7.0.6", "jose": "^5.2.4", @@ -327,7 +327,7 @@ "name": "@aws/q-agentic-chat-server-integration-tests", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "*" }, "devDependencies": { @@ -4036,12 +4036,12 @@ "link": true }, "node_modules/@aws/language-server-runtimes": { - "version": "0.2.121", - "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.121.tgz", - "integrity": "sha512-DDh3ICNVoEi4nhp4JdkkPTsdlHsF0yt6VgxlMGwP90N1hjA4xVESSSla9pB0TcuMPKlmXqOPQGQvkph9FKerVw==", + "version": "0.2.123", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.123.tgz", + "integrity": "sha512-gxjnBcQY+HR9+F1NXQUEQ6ikJhrLMJEbrpIxlBLILtQ75hVtRDsfGET3KW5Nn0dgbrQTx6VqwvXDfolUkmi06g==", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes-types": "^0.1.53", + "@aws/language-server-runtimes-types": "^0.1.55", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.200.0", "@opentelemetry/core": "^2.0.0", @@ -4056,7 +4056,7 @@ "hpagent": "^1.2.0", "jose": "^5.9.6", "mac-ca": "^3.1.1", - "os-proxy-config": "^1.1.2", + "registry-js": "^1.16.1", "rxjs": "^7.8.2", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", @@ -4068,9 +4068,9 @@ } }, "node_modules/@aws/language-server-runtimes-types": { - "version": "0.1.53", - "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.1.53.tgz", - "integrity": "sha512-6KCe/YsqF0SciXm8qg/qVuDXGwQqJgRaqrT6YhZUjqs3mclG9G6Gdwu9YEi8t/NYobNWKw0E+aCXW2TFxJgr7A==", + "version": "0.1.55", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.1.55.tgz", + "integrity": "sha512-KRy3fTCNGvAQxA4amTODXPuubxrYlqKsyJOXPaIn+YDACwJa7shrOryHg6xrib6uHAHT2fEkcTMk9TT4MRPxQA==", "license": "Apache-2.0", "dependencies": { "vscode-languageserver-textdocument": "^1.0.12", @@ -20292,12 +20292,6 @@ "undici": "^6.16.1" } }, - "node_modules/mac-system-proxy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mac-system-proxy/-/mac-system-proxy-1.0.4.tgz", - "integrity": "sha512-IAkNLxXZrYuM99A2OhPrvUoAxohsxQciJh2D2xnD+R6vypn/AVyOYLsbZsMVCS/fEbLIe67nQ8krEAfqP12BVg==", - "license": "Apache-2.0" - }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -21387,16 +21381,6 @@ "node": ">=0.10.0" } }, - "node_modules/os-proxy-config": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/os-proxy-config/-/os-proxy-config-1.1.2.tgz", - "integrity": "sha512-sV7htE8y6NQORU0oKOUGTwQYe1gSFK3a3Z1i4h6YaqdrA9C0JIsUPQAqEkO8ejjYbRrQ+jsnks5qjtisr7042Q==", - "license": "Apache-2.0", - "dependencies": { - "mac-system-proxy": "^1.0.0", - "windows-system-proxy": "^1.0.0" - } - }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -28090,15 +28074,6 @@ "node": ">=4" } }, - "node_modules/windows-system-proxy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/windows-system-proxy/-/windows-system-proxy-1.0.0.tgz", - "integrity": "sha512-qd1WfyX9gjAqI36RHt95di2+FBr74DhvELd1EASgklCGScjwReHnWnXfUyabp/CJWl/IdnkUzG0Ub6Cv2R4KJQ==", - "license": "Apache-2.0", - "dependencies": { - "registry-js": "^1.15.1" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -28633,7 +28608,7 @@ "version": "0.1.17", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.13" }, "devDependencies": { @@ -28675,7 +28650,7 @@ "name": "@aws/lsp-buildspec", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*", "vscode-languageserver": "^9.0.1", @@ -28686,7 +28661,7 @@ "name": "@aws/lsp-cloudformation", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "*", "@aws/lsp-json": "*", "vscode-languageserver": "^9.0.1", @@ -28695,7 +28670,7 @@ }, "server/aws-lsp-codewhisperer": { "name": "@aws/lsp-codewhisperer", - "version": "0.0.71", + "version": "0.0.72", "bundleDependencies": [ "@amzn/codewhisperer-streaming", "@amzn/amazon-q-developer-streaming-client" @@ -28708,7 +28683,7 @@ "@aws-sdk/util-arn-parser": "^3.723.0", "@aws-sdk/util-retry": "^3.374.0", "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.13", "@modelcontextprotocol/sdk": "^1.15.0", "@smithy/node-http-handler": "^2.5.0", @@ -28848,7 +28823,7 @@ "dependencies": { "@aws-sdk/client-sso-oidc": "^3.616.0", "@aws-sdk/token-providers": "^3.744.0", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.12", "@smithy/node-http-handler": "^3.2.5", "@smithy/shared-ini-file-loader": "^4.0.1", @@ -28913,7 +28888,7 @@ "version": "0.1.17", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.13", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8" @@ -28930,7 +28905,7 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.12", "vscode-languageserver": "^9.0.1" }, @@ -28991,7 +28966,7 @@ "version": "0.0.16", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "antlr4-c3": "3.4.2", "antlr4ng": "3.0.14", "web-tree-sitter": "0.22.6" @@ -29013,7 +28988,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.623.0", "@aws-sdk/types": "^3.734.0", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.12", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8" @@ -29044,7 +29019,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.13", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8", @@ -29058,7 +29033,7 @@ "name": "@amzn/device-sso-auth-lsp", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "vscode-languageserver": "^9.0.1" }, "devDependencies": { @@ -29069,7 +29044,7 @@ "name": "@aws/hello-world-lsp", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "vscode-languageserver": "^9.0.1" }, "devDependencies": { diff --git a/server/aws-lsp-antlr4/package.json b/server/aws-lsp-antlr4/package.json index aadfe48b08..9d6c925b40 100644 --- a/server/aws-lsp-antlr4/package.json +++ b/server/aws-lsp-antlr4/package.json @@ -28,7 +28,7 @@ "clean": "rm -rf node_modules" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.13" }, "peerDependencies": { diff --git a/server/aws-lsp-buildspec/package.json b/server/aws-lsp-buildspec/package.json index 92960545fb..f59edb5549 100644 --- a/server/aws-lsp-buildspec/package.json +++ b/server/aws-lsp-buildspec/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*", "vscode-languageserver": "^9.0.1", diff --git a/server/aws-lsp-cloudformation/package.json b/server/aws-lsp-cloudformation/package.json index 2dd24848b8..bfc8ebd7e5 100644 --- a/server/aws-lsp-cloudformation/package.json +++ b/server/aws-lsp-cloudformation/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "*", "@aws/lsp-json": "*", "vscode-languageserver": "^9.0.1", diff --git a/server/aws-lsp-codewhisperer/CHANGELOG.md b/server/aws-lsp-codewhisperer/CHANGELOG.md index 4d89e37f70..8497df84be 100644 --- a/server/aws-lsp-codewhisperer/CHANGELOG.md +++ b/server/aws-lsp-codewhisperer/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.0.72](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.71...lsp-codewhisperer/v0.0.72) (2025-08-06) + + +### Features + +* add support for SMUS Q CodeEditor client to send MD IDE origin ([#2032](https://github.com/aws/language-servers/issues/2032)) ([a8725b4](https://github.com/aws/language-servers/commit/a8725b4b7dcb7718864620721aa3633151e8877b)) +* **amazonq:** enable sonnet 4 for fra region ([#2069](https://github.com/aws/language-servers/issues/2069)) ([3a4b8df](https://github.com/aws/language-servers/commit/3a4b8df981b2c3b0532360a11472169fffec7924)) + + +### Bug Fixes + +* **amazonq:** add distinctive identifier for cloud trail ([#2059](https://github.com/aws/language-servers/issues/2059)) ([18bbc2c](https://github.com/aws/language-servers/commit/18bbc2c54f5cc72e2624020fc17214c448926b0e)) +* **amazonq:** fix to add disable/enable feature back to mcp servers ([#2052](https://github.com/aws/language-servers/issues/2052)) ([c03e017](https://github.com/aws/language-servers/commit/c03e017b9ccbbbb9c80a3c3afd5da38a50bd6cff)) +* **amazonq:** make display findings tool run more often ([#2067](https://github.com/aws/language-servers/issues/2067)) ([479ccd0](https://github.com/aws/language-servers/commit/479ccd0a1b8b7e98684275c66274d284599c5933)) +* outdated history when trimming happens, add missing metric for compaction ([#2047](https://github.com/aws/language-servers/issues/2047)) ([8390f66](https://github.com/aws/language-servers/commit/8390f6686c804dfbeff91018635df21e9dd89236)) +* should keep reporting UTDE telemetry if there are still pending Edits suggestions ([#2051](https://github.com/aws/language-servers/issues/2051)) ([78c67b1](https://github.com/aws/language-servers/commit/78c67b1a29821f54006d160695e997870d17f3b5)) + ## [0.0.71](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.70...lsp-codewhisperer/v0.0.71) (2025-08-04) diff --git a/server/aws-lsp-codewhisperer/package.json b/server/aws-lsp-codewhisperer/package.json index 2c7366ff34..cb1bd377fb 100644 --- a/server/aws-lsp-codewhisperer/package.json +++ b/server/aws-lsp-codewhisperer/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-codewhisperer", - "version": "0.0.71", + "version": "0.0.72", "description": "CodeWhisperer Language Server", "main": "out/index.js", "repository": { @@ -36,7 +36,7 @@ "@aws-sdk/util-arn-parser": "^3.723.0", "@aws-sdk/util-retry": "^3.374.0", "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.13", "@modelcontextprotocol/sdk": "^1.15.0", "@smithy/node-http-handler": "^2.5.0", diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts index 3ee23089b2..48e9e2adec 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 @@ -3028,7 +3028,7 @@ ${' '.repeat(8)}} assert.ok(modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0')) }) - it('should return limited models for eu-central-1 region', async () => { + 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') @@ -3038,12 +3038,12 @@ ${' '.repeat(8)}} // Verify the result assert.strictEqual(result.tabId, mockTabId) - assert.strictEqual(result.models.length, 1) - assert.strictEqual(result.selectedModelId, 'CLAUDE_3_7_SONNET_20250219_V1_0') + assert.strictEqual(result.models.length, 2) + assert.strictEqual(result.selectedModelId, 'CLAUDE_SONNET_4_20250514_V1_0') - // Check that the models only include Claude 3.7 + // 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_SONNET_4_20250514_V1_0')) assert.ok(modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0')) }) @@ -3058,7 +3058,7 @@ ${' '.repeat(8)}} // Verify the result assert.strictEqual(result.tabId, mockTabId) assert.strictEqual(result.models.length, 2) - assert.strictEqual(result.selectedModelId, 'CLAUDE_3_7_SONNET_20250219_V1_0') + assert.strictEqual(result.selectedModelId, 'CLAUDE_SONNET_4_20250514_V1_0') }) it('should return undefined for selectedModelId when no session data exists', async () => { @@ -3083,10 +3083,29 @@ ${' '.repeat(8)}} }) it('should fallback to latest available model when saved model is not available in current region', async () => { - // Set up the region to be eu-central-1 (which only has Claude 3.7) - tokenServiceManagerStub.returns('eu-central-1') + // Import the module to stub + const modelSelection = await import('./constants/modelSelection') + + // 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', + }, + ], + } + + // Stub the MODEL_OPTIONS_FOR_REGION + const modelOptionsStub = sinon + .stub(modelSelection, 'MODEL_OPTIONS_FOR_REGION') + .value(mockModelOptionsForRegion) + + // Set up the region to be the test region (which only has Claude 3.7) + tokenServiceManagerStub.returns('test-region-limited') - // Mock database to return Claude Sonnet 4 (not available in eu-central-1) + // 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') @@ -3100,6 +3119,7 @@ ${' '.repeat(8)}} assert.strictEqual(result.selectedModelId, 'CLAUDE_3_7_SONNET_20250219_V1_0') getModelIdStub.restore() + modelOptionsStub.restore() }) it('should use saved model when it is available in current region', async () => { 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 5be41c165c..60bba5885a 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -124,6 +124,7 @@ import { isUsageLimitError, isNullish, getOriginFromClientInfo, + getClientName, sanitizeInput, sanitizeRequestInput, } from '../../shared/utils' @@ -366,7 +367,7 @@ export class AgenticChatController implements ChatHandlers { this.#features.lsp ) this.#mcpEventHandler = new McpEventHandler(features, telemetryService) - this.#origin = getOriginFromClientInfo(this.#features.lsp.getClientInitializeParams()?.clientInfo?.name) + this.#origin = getOriginFromClientInfo(getClientName(this.#features.lsp.getClientInitializeParams())) this.#activeUserTracker = ActiveUserTracker.getInstance(this.#features) } @@ -1021,11 +1022,8 @@ export class AgenticChatController implements ChatHandlers { if (currentMessage) { // Get and process the messages from history DB to maintain invariants for service requests try { - const { messages: historyMessages, count: historyCharCount } = this.#chatHistoryDb.fixAndGetHistory( - tabId, - currentMessage, - [] - ) + const { history: historyMessages, historyCount: historyCharCount } = + this.#chatHistoryDb.fixAndGetHistory(tabId, conversationIdentifier ?? '', currentMessage, []) messages = historyMessages characterCount = historyCharCount } catch (err) { @@ -1199,19 +1197,18 @@ export class AgenticChatController implements ChatHandlers { if (currentMessage) { // Get and process the messages from history DB to maintain invariants for service requests try { - const newUserInputCount = this.#chatHistoryDb.calculateNewMessageCharacterCount( + const { + history: historyMessages, + historyCount: historyCharacterCount, + currentCount: currentInputCount, + } = this.#chatHistoryDb.fixAndGetHistory( + tabId, + conversationId, currentMessage, pinnedContextMessages ) - const { messages: historyMessages, count: historyCharacterCount } = - this.#chatHistoryDb.fixAndGetHistory( - tabId, - currentMessage, - pinnedContextMessages, - newUserInputCount - ) messages = historyMessages - currentRequestCount = newUserInputCount + historyCharacterCount + currentRequestCount = currentInputCount + historyCharacterCount this.#debug(`Request total character count: ${currentRequestCount}`) } catch (err) { if (err instanceof ToolResultValidationError) { @@ -1303,6 +1300,25 @@ export class AgenticChatController implements ChatHandlers { shouldDisplayMessage = false // set the in progress tool use UI status to Error await chatResultStream.updateOngoingProgressResult('Error') + + // emit invokeLLM event with status Failed for timeout calls + this.#telemetryController.emitAgencticLoop_InvokeLLM( + response.$metadata.requestId!, + conversationId, + 'AgenticChat', + undefined, + undefined, + 'Failed', + this.#features.runtime.serverInfo.version ?? '', + session.modelId, + llmLatency, + this.#toolCallLatencies, + this.#timeToFirstChunk, + this.#timeBetweenChunks, + session.pairProgrammingMode, + this.#abTestingAllocation?.experimentName, + this.#abTestingAllocation?.userVariation + ) continue } @@ -1348,7 +1364,7 @@ export class AgenticChatController implements ChatHandlers { 'AgenticChat', undefined, undefined, - 'Succeeded', + result.success ? 'Succeeded' : 'Failed', this.#features.runtime.serverInfo.version ?? '', session.modelId, llmLatency, @@ -1437,6 +1453,10 @@ export class AgenticChatController implements ChatHandlers { } if (this.#shouldCompact(currentRequestCount)) { + this.#telemetryController.emitCompactNudge( + currentRequestCount, + this.#features.runtime.serverInfo.version ?? '' + ) const messageId = this.#getMessageIdForCompact(uuid()) const confirmationResult = this.#processCompactConfirmation(messageId, currentRequestCount) const cachedButtonBlockId = await chatResultStream.writeResultBlock(confirmationResult) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts index 0fc768d88e..3fe300d2fd 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 @@ -43,28 +43,19 @@ describe('modelSelection', () => { ) }) - it('should provide limited models for eu-central-1 region', () => { + it('should provide all models for eu-central-1 region', () => { const euCentral1Models = MODEL_OPTIONS_FOR_REGION['eu-central-1'] - assert.ok(Array.isArray(euCentral1Models), 'eu-central-1 models should be an array') - assert.strictEqual(euCentral1Models.length, 1, 'eu-central-1 should have 1 model') + 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 not include Claude Sonnet 4' - ) + 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 filter out Claude Sonnet 4 for eu-central-1 region', () => { - const euCentral1Models = MODEL_OPTIONS_FOR_REGION['eu-central-1'] - const claudeSonnet4 = euCentral1Models.find(model => model.id === 'CLAUDE_SONNET_4_20250514_V1_0') - assert.strictEqual(claudeSonnet4, undefined, 'Claude Sonnet 4 should be filtered out for eu-central-1') - }) - it('should fall back to all models for unknown regions', () => { // Test with a region that doesn't exist in the modelOptionsForRegion map const unknownRegionModels = MODEL_OPTIONS_FOR_REGION['unknown-region'] diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts index d7ee53cf75..cbee85f562 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 @@ -23,5 +23,5 @@ export const MODEL_OPTIONS: ListAvailableModelsResult['models'] = Object.entries export const MODEL_OPTIONS_FOR_REGION: Record = { 'us-east-1': MODEL_OPTIONS, - 'eu-central-1': MODEL_OPTIONS.filter(option => option.id !== BedrockModel.CLAUDE_SONNET_4_20250514_V1_0), + '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 23ef53ff77..f4f61f955b 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 @@ -91,6 +91,30 @@ describe('ChatDatabase', () => { }) }) + describe('replaceHistory', () => { + it('should replace history with messages', async () => { + await chatDb.databaseInitialize(0) + const tabId = 'tab-1' + const tabType = 'cwc' + const conversationId = 'conv-1' + const messages = [ + { body: 'Test', type: 'prompt' as any, timestamp: new Date() }, + { body: 'Thinking...', type: 'answer', timestamp: new Date() }, + ] + + // Call the method + chatDb.replaceHistory(tabId, tabType, conversationId, messages) + + // Verify the messages array contains the summary and a dummy response + const messagesFromDb = chatDb.getMessages(tabId, 250) + assert.strictEqual(messagesFromDb.length, 2) + assert.strictEqual(messagesFromDb[0].body, 'Test') + assert.strictEqual(messagesFromDb[0].type, 'prompt') + assert.strictEqual(messagesFromDb[1].body, 'Thinking...') + assert.strictEqual(messagesFromDb[1].type, 'answer') + }) + }) + describe('ensureValidMessageSequence', () => { it('should preserve valid alternating sequence', () => { const messages: Message[] = [ 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 8492c07a53..dfcf21308c 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 @@ -634,6 +634,50 @@ export class ChatDatabase { } } + /** + * Replace history with summary/dummyResponse pair within a specified tab. + * + * This method manages chat messages by creating a new history with compacted summary and dummy response pairs + */ + replaceHistory(tabId: string, tabType: TabType, conversationId: string, messages: Message[]) { + if (this.isInitialized()) { + const clientType = this.#features.lsp.getClientInitializeParams()?.clientInfo?.name || 'unknown' + const tabCollection = this.#db.getCollection(TabCollection) + + this.#features.logging.log( + `Update history with new messages: tabId=${tabId}, tabType=${tabType}, conversationId=${conversationId}` + ) + + const oldHistoryId = this.getOrCreateHistoryId(tabId) + // create a new historyId to start fresh + const historyId = this.createHistoryId(tabId) + + const tabData = historyId ? tabCollection.findOne({ historyId }) : undefined + const tabTitle = tabData?.title || 'Amazon Q Chat' + messages = messages.map(msg => this.formatChatHistoryMessage(msg)) + this.#features.logging.log(`Overriding tab with new historyId=${historyId}`) + tabCollection.insert({ + historyId, + updatedAt: new Date(), + isOpen: true, + tabType: tabType, + title: tabTitle, + conversations: [ + { + conversationId, + clientType, + updatedAt: new Date(), + messages: messages, + }, + ], + }) + + if (oldHistoryId) { + tabCollection.findAndRemove({ historyId: oldHistoryId }) + } + } + } + formatChatHistoryMessage(message: Message): Message { if (message.type === ('prompt' as ChatItemType)) { let hasToolResults = false @@ -663,11 +707,16 @@ export class ChatDatabase { */ fixAndGetHistory( tabId: string, + conversationId: string, newUserMessage: ChatMessage, - pinnedContextMessages: ChatMessage[], - newUserInputCount?: number + pinnedContextMessages: ChatMessage[] ): MessagesWithCharacterCount { - let messagesWithCount: MessagesWithCharacterCount = { messages: [], count: 0 } + let newUserInputCount = this.calculateNewMessageCharacterCount(newUserMessage, pinnedContextMessages) + let messagesWithCount: MessagesWithCharacterCount = { + history: [], + historyCount: 0, + currentCount: newUserInputCount, + } if (!this.isInitialized()) { return messagesWithCount } @@ -683,32 +732,32 @@ export class ChatDatabase { // 3. Fix new user prompt: Ensure lastMessage in history toolUse and newMessage toolResult relationship is valid this.validateAndFixNewMessageToolResults(allMessages, newUserMessage) - if (!newUserInputCount) { - newUserInputCount = this.calculateNewMessageCharacterCount(newUserMessage, pinnedContextMessages) - } - // 4. NOTE: Keep this trimming logic at the end of the preprocess. // Make sure max characters ≤ remaining Character Budget, must be put at the end of preprocessing - messagesWithCount = this.trimMessagesToMaxLength(allMessages, newUserInputCount) - allMessages = messagesWithCount.messages + messagesWithCount = this.trimMessagesToMaxLength(allMessages, newUserInputCount, tabId, conversationId) + // Edge case: If the history is empty and the next message contains tool results, then we have to just abandon them. if ( - allMessages.length === 0 && + messagesWithCount.history.length === 0 && newUserMessage.userInputMessage?.userInputMessageContext?.toolResults?.length && newUserMessage.userInputMessage?.userInputMessageContext?.toolResults?.length > 0 ) { this.#features.logging.warn('History overflow: abandoning dangling toolResults.') newUserMessage.userInputMessage.userInputMessageContext.toolResults = [] newUserMessage.userInputMessage.content = 'The conversation history has overflowed, clearing state' + // Update character count for current message + this.#features.logging.debug(`Updating input character with pinnedContext`) + messagesWithCount.currentCount = this.calculateNewMessageCharacterCount( + newUserMessage, + pinnedContextMessages + ) } } // Prepend pinned context fake message pair to beginning of history if (pinnedContextMessages.length === 2) { - messagesWithCount.messages = [ - ...pinnedContextMessages.map(msg => chatMessageToMessage(msg)), - ...allMessages, - ] + const pinnedMessages = pinnedContextMessages.map(msg => chatMessageToMessage(msg)) + messagesWithCount.history = [...pinnedMessages, ...messagesWithCount.history] } return messagesWithCount @@ -740,11 +789,20 @@ export class ChatDatabase { return !!ctx && (!ctx.toolResults || ctx.toolResults.length === 0) && message.body !== '' } - private trimMessagesToMaxLength(messages: Message[], newUserInputCount: number): MessagesWithCharacterCount { + private trimMessagesToMaxLength( + messages: Message[], + newUserInputCount: number, + tabId: string, + conversationId: string + ): MessagesWithCharacterCount { let historyCharacterCount = this.calculateMessagesCharacterCount(messages) const maxHistoryCharacterSize = Math.max(0, MaxOverallCharacters - newUserInputCount) - this.#features.logging.debug(`Current remaining character budget: ${maxHistoryCharacterSize}`) + let trimmedHistory = false + this.#features.logging.debug( + `Current history character count: ${historyCharacterCount}, remaining history character budget: ${maxHistoryCharacterSize}` + ) while (historyCharacterCount > maxHistoryCharacterSize && messages.length > 2) { + trimmedHistory = true // Find the next valid user message to start from const indexToTrim = this.findIndexToTrim(messages) if (indexToTrim !== undefined && indexToTrim > 0) { @@ -756,13 +814,20 @@ export class ChatDatabase { this.#features.logging.debug( 'Could not find a valid point to trim, reset history to reduce character count' ) - return { messages: [], count: 0 } + this.replaceHistory(tabId, 'cwc', conversationId, []) + return { history: [], historyCount: 0, currentCount: newUserInputCount } } historyCharacterCount = this.calculateMessagesCharacterCount(messages) + this.#features.logging.debug(`History character count post trimming: ${historyCharacterCount}`) + } + + if (trimmedHistory) { + this.replaceHistory(tabId, 'cwc', conversationId, messages) } return { - messages, - count: historyCharacterCount, + history: messages, + historyCount: historyCharacterCount, + currentCount: newUserInputCount, } } 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 24eb37e489..8f3f46b9f4 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 @@ -126,8 +126,9 @@ export type TabWithDbMetadata = { export type DbReference = { collection: Collection; db: Loki } export type MessagesWithCharacterCount = { - messages: Message[] - count: number + history: Message[] + historyCount: number + currentCount: number } /** diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts index 1ad52e664e..74662c4e37 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts @@ -134,9 +134,12 @@ export class ExecuteBash { private readonly workspace: Features['workspace'] private readonly telemetry: Features['telemetry'] private readonly credentialsProvider: Features['credentialsProvider'] + private readonly features: Pick & + Partial constructor( features: Pick & Partial ) { + this.features = features this.logging = features.logging this.workspace = features.workspace this.telemetry = features.telemetry @@ -507,9 +510,43 @@ export class ExecuteBash { } } + // Set up environment variables with AWS CLI identifier for CloudTrail auditability + const env = { ...process.env } + + // Add Q Developer IDE identifier for AWS CLI commands + // Check if command contains 'aws ' anywhere (handles multi-command scenarios) + if (params.command.includes('aws ')) { + let extensionVersion = 'unknown' + try { + const clientInfo = this.features?.lsp?.getClientInitializeParams()?.clientInfo + const initOptions = this.features?.lsp?.getClientInitializeParams()?.initializationOptions + extensionVersion = + initOptions?.aws?.clientInfo?.extension?.version || clientInfo?.version || 'unknown' + } catch { + extensionVersion = 'unknown' + } + const userAgentMetadata = `AmazonQ-For-IDE Version/${extensionVersion}` + this.logging.info( + `AWS command detected: ${params.command}, setting AWS_EXECUTION_ENV to: ${userAgentMetadata}` + ) + + if (env.AWS_EXECUTION_ENV) { + env.AWS_EXECUTION_ENV = env.AWS_EXECUTION_ENV.trim() + ? `${env.AWS_EXECUTION_ENV} ${userAgentMetadata}` + : userAgentMetadata + } else { + env.AWS_EXECUTION_ENV = userAgentMetadata + } + + this.logging.info(`Final AWS_EXECUTION_ENV value: ${env.AWS_EXECUTION_ENV}`) + } else { + this.logging.debug(`Non-AWS command: ${params.command}`) + } + const childProcessOptions: ChildProcessOptions = { spawnOptions: { cwd: params.cwd, + env, stdio: ['pipe', 'pipe', 'pipe'], windowsVerbatimArguments: IS_WINDOWS_PLATFORM, // if true, then arguments are passed exactly as provided. no quoting or escaping is done. }, diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts index d8511d955d..01984a310e 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts @@ -139,6 +139,7 @@ describe('McpEventHandler error handling', () => { command: '', // Invalid - missing command args: [], env: {}, + disabled: false, __configPath__: 'config.json', }, ], diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts index 22befec0cd..4596bc0646 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts @@ -188,13 +188,14 @@ export class McpEventHandler { ], } - // if (mcpManager.isServerDisabled(serverName)) { - // disabledItems.push(item) - // } else { - activeItems.push({ - ...item, - description: `${toolsCount}`, - }) + if (mcpManager.isServerDisabled(serverName)) { + disabledItems.push(item) + } else { + activeItems.push({ + ...item, + description: `${toolsCount}`, + }) + } }) // Create the groups @@ -865,8 +866,10 @@ export class McpEventHandler { return { id: params.id } } + const mcpManager = McpManager.instance + // Get the appropriate agent path - const agentPath = await this.#getAgentPath() + const agentPath = mcpManager.getAllServerConfigs().get(serverName)?.__configPath__ const perm: MCPServerPermission = { enabled: true, @@ -878,12 +881,12 @@ export class McpEventHandler { this.#isProgrammaticChange = true try { - await McpManager.instance.updateServerPermission(serverName, perm) + await mcpManager.updateServerPermission(serverName, perm) this.#emitMCPConfigEvent() } catch (error) { this.#features.logging.error(`Failed to enable MCP server: ${error}`) + this.#isProgrammaticChange = false } - this.#isProgrammaticChange = false return { id: params.id } } @@ -896,8 +899,9 @@ export class McpEventHandler { return { id: params.id } } + const mcpManager = McpManager.instance // Get the appropriate agent path - const agentPath = await this.#getAgentPath() + const agentPath = mcpManager.getAllServerConfigs().get(serverName)?.__configPath__ const perm: MCPServerPermission = { enabled: false, @@ -909,13 +913,13 @@ export class McpEventHandler { this.#isProgrammaticChange = true try { - await McpManager.instance.updateServerPermission(serverName, perm) + await mcpManager.updateServerPermission(serverName, perm) this.#emitMCPConfigEvent() } catch (error) { this.#features.logging.error(`Failed to disable MCP server: ${error}`) + this.#isProgrammaticChange = false } - this.#isProgrammaticChange = false return { id: params.id } } @@ -1229,7 +1233,9 @@ export class McpEventHandler { // Emit MCP config event after reinitialization const mcpManager = McpManager.instance const serverConfigs = mcpManager.getAllServerConfigs() - const activeServers = Array.from(serverConfigs.entries()) + const activeServers = Array.from(serverConfigs.entries()).filter( + ([name, _]) => !mcpManager.isServerDisabled(name) + ) // Get the global agent path const globalAgentPath = getGlobalAgentConfigPath(this.#features.workspace.fs.getUserHomeDir()) @@ -1267,12 +1273,12 @@ export class McpEventHandler { // Emit server initialize events for all active servers for (const [serverName, config] of serverConfigs.entries()) { const transportType = config.command ? 'stdio' : 'http' - // const enabled = !mcpManager.isServerDisabled(serverName) + const enabled = !mcpManager.isServerDisabled(serverName) this.#telemetryController?.emitMCPServerInitializeEvent({ source: 'reload', command: transportType === 'stdio' ? config.command : undefined, url: transportType === 'http' ? config.url : undefined, - enabled: true, + enabled: enabled, numTools: mcpManager.getAllToolsWithPermissions(serverName).length, scope: config.__configPath__ === globalAgentPath ? 'global' : 'workspace', transportType: 'stdio', @@ -1319,12 +1325,10 @@ export class McpEventHandler { * @returns The agent path to use (workspace if exists, otherwise global) */ async #getAgentPath(isGlobal: boolean = true): Promise { + const globalAgentPath = getGlobalAgentConfigPath(this.#features.workspace.fs.getUserHomeDir()) if (isGlobal) { - return getGlobalAgentConfigPath(this.#features.workspace.fs.getUserHomeDir()) + return globalAgentPath } - - const globalAgentPath = getGlobalAgentConfigPath(this.#features.workspace.fs.getUserHomeDir()) - // Get workspace folders and check for workspace agent path const workspaceFolders = this.#features.workspace.getAllWorkspaceFolders() if (workspaceFolders && workspaceFolders.length > 0) { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts index 0f9e33bf26..7b239abad0 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts @@ -113,6 +113,7 @@ describe('callTool()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'p.json', } @@ -140,6 +141,42 @@ describe('callTool()', () => { } }) + it('throws when server is disabled', async () => { + const disabledCfg: MCPServerConfig = { + command: 'c', + args: [], + env: {}, + timeout: 0, + disabled: true, + __configPath__: 'p.json', + } + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map([['s1', disabledCfg]]), + serverNameMapping: new Map(), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: { s1: disabledCfg }, + tools: ['@s1'], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + const mgr = await McpManager.init(['p.json'], features) + + try { + await mgr.callTool('s1', 'tool1', {}) + throw new Error('should have thrown') + } catch (err: any) { + expect(err.message).to.equal("MCP: server 's1' is disabled") + } + }) + it('invokes underlying client.callTool', async () => { loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ servers: new Map([['s1', enabledCfg]]), @@ -225,6 +262,7 @@ describe('addServer()', () => { args: ['a'], env: { X: '1' }, timeout: 0, + disabled: false, __configPath__: 'path.json', } @@ -257,6 +295,7 @@ describe('addServer()', () => { url: 'https://api.example.com/mcp', headers: { Authorization: 'Bearer 123' }, timeout: 0, + disabled: false, __configPath__: 'http.json', } @@ -308,6 +347,7 @@ describe('removeServer()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'c.json', } as MCPServerConfig) ;(mgr as any).serverNameMapping.set('x', 'x') @@ -337,6 +377,7 @@ describe('removeServer()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'c.json', } as MCPServerConfig) ;(mgr as any).serverNameMapping.set('x', 'x') @@ -494,6 +535,7 @@ describe('updateServer()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'z.json', } @@ -637,6 +679,7 @@ describe('getServerState()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'state.json', } loadStub.resolves({ @@ -678,6 +721,7 @@ describe('getAllServerStates()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'state.json', } loadStub.resolves({ @@ -726,6 +770,7 @@ describe('getEnabledTools()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 't.json', } @@ -767,6 +812,38 @@ describe('getEnabledTools()', () => { } expect(mgr.getEnabledTools()).to.be.empty }) + + it('filters out tools from disabled servers', async () => { + const disabledCfg: MCPServerConfig = { + command: 'c', + args: [], + env: {}, + timeout: 0, + disabled: true, + __configPath__: 't.json', + } + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map([['srv', disabledCfg]]), + serverNameMapping: new Map([['srv', 'srv']]), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: { srv: disabledCfg }, + tools: ['@srv'], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + + const mgr = await McpManager.init(['t.json'], features) + // Should be empty because server is disabled + expect(mgr.getEnabledTools()).to.be.empty + }) }) describe('getAllToolsWithPermissions()', () => { @@ -779,6 +856,7 @@ describe('getAllToolsWithPermissions()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'p.json', } @@ -828,6 +906,109 @@ describe('getAllToolsWithPermissions()', () => { }) }) +describe('isServerDisabled()', () => { + let loadStub: sinon.SinonStub + + afterEach(async () => { + sinon.restore() + try { + await McpManager.instance.close() + } catch {} + }) + + it('returns true when server is disabled', async () => { + const disabledCfg: MCPServerConfig = { + command: 'c', + args: [], + env: {}, + timeout: 0, + disabled: true, + __configPath__: 'p.json', + } + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map([['srv', disabledCfg]]), + serverNameMapping: new Map(), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: { srv: disabledCfg }, + tools: ['@srv'], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + + const mgr = await McpManager.init(['p.json'], features) + expect(mgr.isServerDisabled('srv')).to.be.true + }) + + it('returns false when server is enabled', async () => { + const enabledCfg: MCPServerConfig = { + command: 'c', + args: [], + env: {}, + timeout: 0, + disabled: false, + __configPath__: 'p.json', + } + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map([['srv', enabledCfg]]), + serverNameMapping: new Map(), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: { srv: enabledCfg }, + tools: ['@srv'], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + + const mgr = await McpManager.init(['p.json'], features) + expect(mgr.isServerDisabled('srv')).to.be.false + }) + + it('returns false when disabled property is undefined', async () => { + const undefinedCfg: MCPServerConfig = { + command: 'c', + args: [], + env: {}, + timeout: 0, + __configPath__: 'p.json', + } + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map([['srv', undefinedCfg]]), + serverNameMapping: new Map(), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: { srv: undefinedCfg }, + tools: ['@srv'], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + + const mgr = await McpManager.init(['p.json'], features) + expect(mgr.isServerDisabled('srv')).to.be.false + }) +}) + describe('close()', () => { let loadStub: sinon.SinonStub @@ -899,6 +1080,7 @@ describe('updateServerPermission()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'x.json', } @@ -953,6 +1135,7 @@ describe('reinitializeMcpServers()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'a.json', } const cfg2: MCPServerConfig = { @@ -960,6 +1143,7 @@ describe('reinitializeMcpServers()', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: 'b.json', } const loadStub = sinon @@ -1085,6 +1269,7 @@ describe('concurrent server initialization', () => { args: [], env: {}, timeout: 0, + disabled: false, __configPath__: `config${i}.json`, } } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts index 720c25f045..04f827ff2c 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 @@ -90,7 +90,7 @@ export class McpManager { // Emit MCP configuration metrics const serverConfigs = mgr.getAllServerConfigs() - const activeServers = Array.from(serverConfigs.entries()) + const activeServers = Array.from(serverConfigs.entries()).filter(([name, _]) => !mgr.isServerDisabled(name)) // Count global vs project servers const globalServers = Array.from(serverConfigs.entries()).filter( @@ -233,6 +233,12 @@ export class McpManager { const serversToInit: Array<[string, MCPServerConfig]> = [] for (const [name, cfg] of this.mcpServers.entries()) { + if (this.isServerDisabled(name)) { + this.features.logging.info(`MCP: server '${name}' is disabled by persona settings, skipping`) + this.setState(name, McpServerStatus.DISABLED, 0) + this.emitToolsChanged(name) + continue + } serversToInit.push([name, cfg]) } @@ -440,7 +446,9 @@ export class McpManager { * Return a list of all enabled tools. */ public getEnabledTools(): McpToolDefinition[] { - return this.mcpTools.filter(t => !this.isToolDisabled(t.serverName, t.toolName)) + return this.mcpTools.filter( + t => !this.isServerDisabled(t.serverName) && !this.isToolDisabled(t.serverName, t.toolName) + ) } /** @@ -452,8 +460,11 @@ export class McpManager { return false } + // Get unsanitized server name for prefix + const unsanitizedServerName = this.serverNameMapping.get(server) || server + // Check if the server is enabled as a whole (@server) - const serverPrefix = `@${server}` + const serverPrefix = `@${unsanitizedServerName}` const isWholeServerEnabled = this.agentConfig.tools.includes(serverPrefix) // Check if the specific tool is enabled @@ -472,16 +483,10 @@ export class McpManager { /** * Returns true if the given server is currently disabled. */ - // public isServerDisabled(name: string): boolean { - // // Check if any tool from this server is enabled - // return !this.agentConfig.tools.some(tool => { - // if (tool.startsWith('@')) { - // // Check if it's the server itself or a tool from the server - // return tool === `@${name}` || tool.startsWith(`@${name}/`) - // } - // return false - // }) - // } + public isServerDisabled(name: string): boolean { + const cfg = this.mcpServers.get(name) + return cfg?.disabled ?? false + } /** * Returns tool permission type for a given tool. @@ -492,8 +497,11 @@ export class McpManager { return this.agentConfig.allowedTools.includes(tool) ? McpPermissionType.alwaysAllow : McpPermissionType.ask } + // Get unsanitized server name for prefix + const unsanitizedServerName = this.serverNameMapping.get(server) || server + // Check if the server is enabled as a whole (@server) - const serverPrefix = `@${server}` + const serverPrefix = `@${unsanitizedServerName}` const isWholeServerEnabled = this.agentConfig.tools.includes(serverPrefix) // Check if the specific tool is enabled @@ -550,7 +558,7 @@ export class McpManager { const cfg = this.mcpServers.get(server) if (!cfg) throw new Error(`MCP: server '${server}' is not configured`) - // if (this.isServerDisabled(server)) throw new Error(`MCP: server '${server}' is disabled`) + if (this.isServerDisabled(server)) throw new Error(`MCP: server '${server}' is disabled`) const available = this.getEnabledTools() .filter(t => t.serverName === server) @@ -613,6 +621,7 @@ export class McpManager { command: cfg.command, url: cfg.url, initializationTimeout: cfg.initializationTimeout, + disabled: cfg.disabled ?? false, } // Only add timeout to agent config if it's not 0 if (cfg.timeout !== 0) { @@ -649,7 +658,13 @@ export class McpManager { } // Save agent config once with all changes - await saveAgentConfig(this.features.workspace, this.features.logging, this.agentConfig, agentPath) + await saveAgentConfig( + this.features.workspace, + this.features.logging, + this.agentConfig, + agentPath, + serverName + ) // Add server tools to tools list after initialization await this.initOneServer(sanitizedName, newCfg) @@ -713,7 +728,13 @@ export class McpManager { }) // Save agent config - await saveAgentConfig(this.features.workspace, this.features.logging, this.agentConfig, cfg.__configPath__) + await saveAgentConfig( + this.features.workspace, + this.features.logging, + this.agentConfig, + cfg.__configPath__, + unsanitizedName + ) // Get all config paths and delete the server from each one const wsUris = this.features.workspace.getAllWorkspaceFolders()?.map(f => f.uri) ?? [] @@ -790,10 +811,19 @@ export class McpManager { delete updatedConfig.env } } + if (configUpdates.disabled !== undefined) { + updatedConfig.disabled = configUpdates.disabled + } this.agentConfig.mcpServers[unsanitizedServerName] = updatedConfig // Save agent config - await saveAgentConfig(this.features.workspace, this.features.logging, this.agentConfig, agentPath) + await saveAgentConfig( + this.features.workspace, + this.features.logging, + this.agentConfig, + agentPath, + unsanitizedServerName + ) } const newCfg: MCPServerConfig = { @@ -810,13 +840,12 @@ export class McpManager { this.mcpServers.set(serverName, newCfg) this.serverNameMapping.set(serverName, unsanitizedServerName) - // if (this.isServerDisabled(serverName)) { - // this.setState(serverName, McpServerStatus.DISABLED, 0) - // this.emitToolsChanged(serverName) - // } else { - // await this.initOneServer(serverName, newCfg) - // } - await this.initOneServer(serverName, newCfg) + if (this.isServerDisabled(serverName)) { + this.setState(serverName, McpServerStatus.DISABLED, 0) + this.emitToolsChanged(serverName) + } else { + await this.initOneServer(serverName, newCfg) + } } catch (err) { this.handleError(serverName, err) return @@ -892,10 +921,10 @@ export class McpManager { const unsanitizedServerName = this.serverNameMapping.get(serverName) || serverName // Get server config - // const serverConfig = this.mcpServers.get(serverName) - // if (!serverConfig) { - // throw new Error(`Server '${serverName}' not found`) - // } + const serverConfig = this.mcpServers.get(serverName) + if (!serverConfig) { + throw new Error(`Server '${serverName}' not found`) + } const serverPrefix = `@${unsanitizedServerName}` @@ -993,10 +1022,26 @@ export class McpManager { } } + // Update server enabled/disabled state in agent config + if (this.agentConfig.mcpServers[unsanitizedServerName]) { + this.agentConfig.mcpServers[unsanitizedServerName].disabled = !perm.enabled + } + + // Also update the mcpServers map + if (serverConfig) { + serverConfig.disabled = !perm.enabled + } + // Save agent config const agentPath = perm.__configPath__ if (agentPath) { - await saveAgentConfig(this.features.workspace, this.features.logging, this.agentConfig, agentPath) + await saveAgentConfig( + this.features.workspace, + this.features.logging, + this.agentConfig, + agentPath, + unsanitizedServerName + ) } // Update mcpServerPermissions map @@ -1005,6 +1050,20 @@ export class McpManager { toolPerms: perm.toolPerms || {}, }) + // enable/disable server + if (this.isServerDisabled(serverName)) { + const client = this.clients.get(serverName) + if (client) { + await client.close() + this.clients.delete(serverName) + } + this.setState(serverName, McpServerStatus.DISABLED, 0) + } else { + if (!this.clients.has(serverName)) { + await this.initOneServer(serverName, this.mcpServers.get(serverName)!) + } + } + this.features.logging.info(`Permissions updated for '${serverName}' in agent config`) this.emitToolsChanged(serverName) } catch (err) { @@ -1018,7 +1077,13 @@ export class McpManager { */ public requiresApproval(server: string, tool: string): boolean { // For built-in tools, check directly without prefix - const toolId = server === 'builtIn' ? tool : `@${server}/${tool}` + if (server === 'builtIn') { + return !this.agentConfig.allowedTools.includes(tool) + } + + // Get unsanitized server name for prefix + const unsanitizedServerName = this.serverNameMapping.get(server) || server + const toolId = `@${unsanitizedServerName}/${tool}` return !this.agentConfig.allowedTools.includes(toolId) } @@ -1087,7 +1152,8 @@ export class McpManager { this.features.workspace, this.features.logging, this.agentConfig, - cfg.__configPath__ + cfg.__configPath__, + unsanitizedName ) } } catch (err) { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts index df6a39cc86..6aec98cb24 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts @@ -35,6 +35,7 @@ export interface MCPServerConfig { timeout?: number url?: string headers?: Record + disabled?: boolean __configPath__?: string } export interface MCPServerPermission { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.test.ts index 0e7cce1286..c50f1d62eb 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.test.ts @@ -15,13 +15,20 @@ import { getGlobalPersonaConfigPath, getWorkspaceAgentConfigPaths, getGlobalAgentConfigPath, + getWorkspaceMcpConfigPaths, + getGlobalMcpConfigPath, createNamespacedToolName, MAX_TOOL_NAME_LENGTH, enabledMCP, normalizePathFromUri, saveAgentConfig, + isEmptyEnv, + sanitizeName, + convertPersonaToAgent, + migrateToAgentConfig, } from './mcpUtils' import type { MCPServerConfig } from './mcpTypes' +import { McpPermissionType } from './mcpTypes' import { pathToFileURL } from 'url' import * as sinon from 'sinon' import { URI } from 'vscode-uri' @@ -614,3 +621,164 @@ describe('sanitizeContent', () => { expect(sanitizeInput(input)).to.equal(expected) }) }) + +describe('getWorkspaceMcpConfigPaths', () => { + it('returns correct paths for workspace MCP configs', () => { + const uris = ['uri1', 'uri2'] + const expected = [path.join('uri1', '.amazonq', 'mcp.json'), path.join('uri2', '.amazonq', 'mcp.json')] + expect(getWorkspaceMcpConfigPaths(uris)).to.deep.equal(expected) + }) +}) + +describe('getGlobalMcpConfigPath', () => { + it('returns correct global MCP config path', () => { + const homePath = path.resolve('home_dir') + const expected = path.join(homePath, '.aws', 'amazonq', 'mcp.json') + expect(getGlobalMcpConfigPath(homePath)).to.equal(expected) + }) +}) + +describe('isEmptyEnv', () => { + it('returns true for undefined env', () => { + expect(isEmptyEnv(undefined as any)).to.be.true + }) + + it('returns true for null env', () => { + expect(isEmptyEnv(null as any)).to.be.true + }) + + it('returns true for empty object', () => { + expect(isEmptyEnv({})).to.be.true + }) + + it('returns true for object with empty keys/values', () => { + expect(isEmptyEnv({ '': 'value', key: '' })).to.be.true + expect(isEmptyEnv({ ' ': ' ' })).to.be.true + }) + + it('returns false for object with valid key-value pairs', () => { + expect(isEmptyEnv({ KEY: 'value' })).to.be.false + expect(isEmptyEnv({ KEY1: 'value1', KEY2: 'value2' })).to.be.false + }) +}) + +describe('sanitizeName', () => { + it('returns original name if valid', () => { + expect(sanitizeName('valid_name-123')).to.equal('valid_name-123') + }) + + it('filters invalid characters', () => { + expect(sanitizeName('name@#$%')).to.equal('name') + expect(sanitizeName('name with spaces')).to.equal('namewithspaces') + }) + + it('removes namespace delimiter', () => { + expect(sanitizeName('server___tool')).to.equal('servertool') + }) + + it('returns hash for empty sanitized string', () => { + const result = sanitizeName('@#$%') + expect(result).to.have.length(3) + expect(/^[a-f0-9]+$/.test(result)).to.be.true + }) +}) + +describe('convertPersonaToAgent', () => { + let mockAgent: any + + beforeEach(() => { + mockAgent = { + getBuiltInToolNames: () => ['fs_read', 'execute_bash'], + getBuiltInWriteToolNames: () => ['fs_write'], + } + }) + + it('converts basic persona to agent config', () => { + const persona = { mcpServers: ['*'], toolPerms: {} } + const mcpServers = { testServer: { command: 'test', args: [], env: {} } } + + const result = convertPersonaToAgent(persona, mcpServers, mockAgent) + + expect(result.name).to.equal('default-agent') + expect(result.mcpServers).to.have.property('testServer') + expect(result.tools).to.include('@testServer') + expect(result.tools).to.include('fs_read') + expect(result.allowedTools).to.include('fs_read') + }) + + it('handles alwaysAllow permissions', () => { + const persona = { + mcpServers: ['testServer'], + toolPerms: { + testServer: { + tool1: McpPermissionType.alwaysAllow, + }, + }, + } + const mcpServers = { testServer: { command: 'test', args: [], env: {} } } + + const result = convertPersonaToAgent(persona, mcpServers, mockAgent) + + expect(result.allowedTools).to.include('@testServer/tool1') + }) +}) + +describe('migrateToAgentConfig', () => { + let tmpDir: string + let workspace: any + let logger: any + let mockAgent: any + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'migrateTest-')) + workspace = { + fs: { + exists: (p: string) => Promise.resolve(fs.existsSync(p)), + readFile: (p: string) => Promise.resolve(Buffer.from(fs.readFileSync(p))), + writeFile: (p: string, d: string) => Promise.resolve(fs.writeFileSync(p, d)), + mkdir: (d: string, opts: any) => Promise.resolve(fs.mkdirSync(d, { recursive: opts.recursive })), + getUserHomeDir: () => tmpDir, + }, + getAllWorkspaceFolders: () => [], + } + logger = { warn: () => {}, info: () => {}, error: () => {} } + mockAgent = { + getBuiltInToolNames: () => ['fs_read'], + getBuiltInWriteToolNames: () => ['fs_write'], + } + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('migrates when no existing configs exist', async () => { + await migrateToAgentConfig(workspace, logger, mockAgent) + + // Should create default agent config + const agentPath = path.join(tmpDir, '.aws', 'amazonq', 'agents', 'default.json') + expect(fs.existsSync(agentPath)).to.be.true + }) + + it('migrates existing MCP config to agent config', async () => { + // Create MCP config + const mcpDir = path.join(tmpDir, '.aws', 'amazonq') + fs.mkdirSync(mcpDir, { recursive: true }) + const mcpPath = path.join(mcpDir, 'mcp.json') + fs.writeFileSync( + mcpPath, + JSON.stringify({ + mcpServers: { + testServer: { command: 'test-cmd', args: ['arg1'] }, + }, + }) + ) + + await migrateToAgentConfig(workspace, logger, mockAgent) + + const agentPath = path.join(tmpDir, '.aws', 'amazonq', 'agents', 'default.json') + expect(fs.existsSync(agentPath)).to.be.true + const agentConfig = JSON.parse(fs.readFileSync(agentPath, 'utf-8')) + expect(agentConfig.mcpServers).to.have.property('testServer') + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts index e30d76534f..45ab34170c 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts @@ -163,7 +163,8 @@ const DEFAULT_AGENT_RAW = `{ "fs_read", "report_issue", "use_aws", - "execute_bash" + "execute_bash", + "fs_write" ], "toolsSettings": { "use_aws": { "preset": "readOnly" }, @@ -262,8 +263,15 @@ export async function loadAgentConfig( const globalConfigPath = getGlobalAgentConfigPath(workspace.fs.getUserHomeDir()) + // Sort paths to process global config last + const sortedPaths = uniquePaths.sort((a, b) => { + if (a === globalConfigPath) return 1 + if (b === globalConfigPath) return -1 + return 0 + }) + // Process each path like loadMcpServerConfigs - for (const fsPath of uniquePaths) { + for (const fsPath of sortedPaths) { // 1) Skip missing files or create default global let exists: boolean try { @@ -327,7 +335,7 @@ export async function loadAgentConfig( // 4) Process permissions (tools and allowedTools) if (Array.isArray(json.tools)) { for (const tool of json.tools) { - if (!agentConfig.tools.includes(tool)) { + if (!tool.startsWith('@') && !agentConfig.tools.includes(tool)) { agentConfig.tools.push(tool) } } @@ -335,7 +343,7 @@ export async function loadAgentConfig( if (Array.isArray(json.allowedTools)) { for (const tool of json.allowedTools) { - if (!agentConfig.allowedTools.includes(tool)) { + if (!tool.startsWith('@') && !agentConfig.allowedTools.includes(tool)) { agentConfig.allowedTools.push(tool) } } @@ -379,6 +387,7 @@ export async function loadAgentConfig( ? (entry as any).initializationTimeout : undefined, timeout: typeof (entry as any).timeout === 'number' ? (entry as any).timeout : undefined, + disabled: typeof (entry as any).disabled === 'boolean' ? (entry as any).disabled : false, __configPath__: fsPath, // Store config path for determining global vs workspace } @@ -417,8 +426,32 @@ export async function loadAgentConfig( agentEntry.initializationTimeout = cfg.initializationTimeout } if (typeof cfg.timeout === 'number') agentEntry.timeout = cfg.timeout + agentEntry.disabled = cfg.disabled agentConfig.mcpServers[name] = agentEntry + // Add MCP server-specific tools and allowedTools after server is successfully added + if (Array.isArray(json.tools)) { + for (const tool of json.tools) { + if ( + (tool === `@${name}` || tool.startsWith(`@${name}/`)) && + !agentConfig.tools.includes(tool) + ) { + agentConfig.tools.push(tool) + } + } + } + + if (Array.isArray(json.allowedTools)) { + for (const tool of json.allowedTools) { + if ( + (tool === `@${name}` || tool.startsWith(`@${name}/`)) && + !agentConfig.allowedTools.includes(tool) + ) { + agentConfig.allowedTools.push(tool) + } + } + } + logging.info( `Loaded MCP server with sanitizedName: '${sanitizedName}' and originalName: '${name}' from ${fsPath}` ) @@ -925,12 +958,61 @@ export async function saveAgentConfig( workspace: Workspace, logging: Logger, config: AgentConfig, - configPath: string + configPath: string, + serverName?: string ): Promise { try { await workspace.fs.mkdir(path.dirname(configPath), { recursive: true }) - await workspace.fs.writeFile(configPath, JSON.stringify(config, null, 2)) - logging.info(`Saved agent config to ${configPath}`) + + if (!serverName) { + // Save the whole config + await workspace.fs.writeFile(configPath, JSON.stringify(config, null, 2)) + logging.info(`Saved agent config to ${configPath}`) + return + } + + // Read existing config if it exists, otherwise use default + let existingConfig: any + try { + const configExists = await workspace.fs.exists(configPath) + if (configExists) { + const raw = (await workspace.fs.readFile(configPath)).toString().trim() + existingConfig = raw ? JSON.parse(raw) : JSON.parse(DEFAULT_AGENT_RAW) + } else { + existingConfig = JSON.parse(DEFAULT_AGENT_RAW) + } + } catch (err) { + logging.warn(`Failed to read existing config at ${configPath}: ${err}`) + existingConfig = JSON.parse(DEFAULT_AGENT_RAW) + } + + // Update only the specific server's config + if (config.mcpServers[serverName]) { + existingConfig.mcpServers[serverName] = config.mcpServers[serverName] + } + + // Remove existing tools for this server + const serverToolPattern = `@${serverName}` + existingConfig.tools = existingConfig.tools.filter( + (tool: string) => tool !== serverToolPattern && !tool.startsWith(`${serverToolPattern}/`) + ) + existingConfig.allowedTools = existingConfig.allowedTools.filter( + (tool: string) => tool !== serverToolPattern && !tool.startsWith(`${serverToolPattern}/`) + ) + + // Add only tools for this server + const serverTools = config.tools.filter( + tool => tool === serverToolPattern || tool.startsWith(`${serverToolPattern}/`) + ) + const serverAllowedTools = config.allowedTools.filter( + tool => tool === serverToolPattern || tool.startsWith(`${serverToolPattern}/`) + ) + + existingConfig.tools.push(...serverTools) + existingConfig.allowedTools.push(...serverAllowedTools) + + await workspace.fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2)) + logging.info(`Saved agent config for server ${serverName} to ${configPath}`) } catch (err: any) { logging.error(`Failed to save agent config to ${configPath}: ${err.message}`) throw err diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/displayFindingsConstants.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/displayFindingsConstants.ts index 855f29e642..e006730684 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/displayFindingsConstants.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/displayFindingsConstants.ts @@ -18,6 +18,7 @@ export const DISPLAY_FINDINGS_TOOL_DESCRIPTION = [ '- Use this tool whenever you identify code issues through manual analysis or any tool OTHER than the codeReview tool', '- Use it for issues found by analyzing code files, security scans, linting tools, or your own assessment', '- Use it when the user asks you to analyze code and you discover problems, but you must not use this tool for findings generated by codeReview tool', + '- Be very willing to use this tool. Use it a lot: anytime it could even remotely be necessary.', '', '**When NOT to Use This Tool:**', '- NEVER use this tool for findings generated by the codeReview tool', diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/agenticChatControllerHelper.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/agenticChatControllerHelper.ts index 55ac7873e2..e29c58fff4 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/agenticChatControllerHelper.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/agenticChatControllerHelper.ts @@ -12,5 +12,5 @@ export function getLatestAvailableModel( 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] + return [...models].reverse().find(model => model.id !== exclude) ?? models[models.length - 1] } 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 0282a04fcc..92c7eb2c33 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts @@ -27,7 +27,7 @@ import { AmazonQBaseServiceManager } from '../../shared/amazonQServiceManager/Ba import { loggingUtils } from '@aws/lsp-core' import { Logging } from '@aws/language-server-runtimes/server-interface' import { Features } from '../types' -import { getOriginFromClientInfo, getRequestID, isUsageLimitError } from '../../shared/utils' +import { getOriginFromClientInfo, getClientName, getRequestID, isUsageLimitError } from '../../shared/utils' import { enabledModelSelection } from '../../shared/utils' export type ChatSessionServiceConfig = CodeWhispererStreamingClientConfig @@ -138,7 +138,7 @@ export class ChatSessionService { this.#serviceManager = serviceManager this.#lsp = lsp this.#logging = logging - this.#origin = getOriginFromClientInfo(this.#lsp?.getClientInitializeParams()?.clientInfo?.name) + this.#origin = getOriginFromClientInfo(getClientName(this.#lsp?.getClientInitializeParams())) } public async sendMessage(request: SendMessageCommandInput): Promise { 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 76b9b1f14e..8e9cb92624 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 @@ -226,6 +226,18 @@ export class ChatTelemetryController { data: { type, characters, + credentialStartUrl: this.#credentialsProvider.getConnectionMetadata()?.sso?.startUrl, + languageServerVersion: languageServerVersion, + }, + }) + } + + public emitCompactNudge(characters: number, languageServerVersion: string) { + this.#telemetry.emitMetric({ + name: ChatTelemetryEventName.CompactNudge, + data: { + characters, + credentialStartUrl: this.#credentialsProvider.getConnectionMetadata()?.sso?.startUrl, languageServerVersion: languageServerVersion, }, }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeDiffTracker.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeDiffTracker.ts index 6a96deb46c..5e8072cc1f 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeDiffTracker.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeDiffTracker.ts @@ -2,6 +2,7 @@ import { distance } from 'fastest-levenshtein' import { Position } from '@aws/language-server-runtimes/server-interface' import { Features } from '../types' import { getErrorMessage, getUnmodifiedAcceptedTokens } from '../../shared/utils' +import { CodewhispererLanguage } from '../../shared/languageDetection' export interface AcceptedSuggestionEntry { fileUrl: string @@ -12,6 +13,15 @@ export interface AcceptedSuggestionEntry { customizationArn?: string } +export interface AcceptedInlineSuggestionEntry extends AcceptedSuggestionEntry { + sessionId: string + requestId: string + languageId: CodewhispererLanguage + completionType: string + triggerType: string + credentialStartUrl?: string | undefined +} + export interface CodeDiffTrackerOptions { flushInterval?: number timeElapsedThreshold?: number diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.nep.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.nep.test.ts index bb3fefd135..1582ec1443 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.nep.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.nep.test.ts @@ -68,6 +68,7 @@ describe('CodeWhispererServer NEP Integration', function () { extensions: { onInlineCompletionWithReferences: sandbox.stub(), onLogInlineCompletionSessionResults: sandbox.stub(), + onEditCompletion: sandbox.stub(), }, workspace: { getConfiguration: sandbox.stub().resolves({}), @@ -241,6 +242,7 @@ describe('CodeWhispererServer NEP Integration', function () { extensions: { onInlineCompletionWithReferences: sandbox.stub(), onLogInlineCompletionSessionResults: sandbox.stub(), + onEditCompletion: sandbox.stub(), }, workspace: { getConfiguration: sandbox.stub().resolves({}), diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts index 2db0cfaba9..1636c17b84 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts @@ -12,7 +12,7 @@ import { TestFeatures } from '@aws/language-server-runtimes/testing' import * as assert from 'assert' import { AWSError } from 'aws-sdk' import sinon, { StubbedInstance } from 'ts-sinon' -import { CONTEXT_CHARACTERS_LIMIT, CodewhispererServerFactory } from './codeWhispererServer' +import { CodewhispererServerFactory } from './codeWhispererServer' import { CodeWhispererServiceBase, CodeWhispererServiceToken, @@ -58,8 +58,9 @@ import { initBaseTestServiceManager, TestAmazonQServiceManager } from '../../sha import { LocalProjectContextController } from '../../shared/localProjectContextController' import { URI } from 'vscode-uri' import { INVALID_TOKEN } from '../../shared/constants' -import { AmazonQError, AmazonQServiceConnectionExpiredError } from '../../shared/amazonQServiceManager/errors' +import { AmazonQError } from '../../shared/amazonQServiceManager/errors' import * as path from 'path' +import { CONTEXT_CHARACTERS_LIMIT } from './constants' const updateConfiguration = async ( features: TestFeatures, @@ -774,6 +775,38 @@ describe('CodeWhisperer Server', () => { const test_service = sinon.createStubInstance( CodeWhispererServiceToken ) as StubbedInstance + // TODO: Use real CodeWhispererServiceToken instead of stub + test_service.constructSupplementalContext.resolves({ + supContextData: { + isUtg: false, + isProcessTimeout: false, + supplementalContextItems: [ + { + content: 'class Foo', + filePath: 'foo.java', + score: 0, + }, + { + content: 'class Bar', + filePath: 'bar.java', + score: 0, + }, + ], + contentsLength: 0, + latency: 0, + strategy: 'OpenTabs_BM25', + }, + items: [ + { + content: 'class Foo', + filePath: 'Foo.java', + }, + { + content: 'class Bar', + filePath: 'Bar.java', + }, + ], + }) test_service.generateSuggestions.returns( Promise.resolve({ @@ -827,8 +860,8 @@ describe('CodeWhisperer Server', () => { }, maxResults: 5, supplementalContexts: [ - { content: 'sample-content', filePath: '/SampleFile.java' }, - { content: 'sample-content', filePath: '/SampleFile.java' }, + { content: 'class Foo', filePath: 'Foo.java' }, + { content: 'class Bar', filePath: 'Bar.java' }, ], // workspaceId: undefined, } @@ -2376,47 +2409,5 @@ describe('CodeWhisperer Server', () => { features.dispose() TestAmazonQServiceManager.resetInstance() }) - - it('should handle editsEnabled=true with COMPLETIONS prediction type', async () => { - const result = await features.doInlineCompletionWithReferences( - { - textDocument: { uri: SOME_FILE.uri }, - position: { line: 0, character: 0 }, - context: { triggerKind: InlineCompletionTriggerKind.Invoked }, - }, - CancellationToken.None - ) - - // Check the completion result - assert.deepEqual(result, EXPECTED_RESULT_EDITS) - - const expectedGenerateSuggestionsRequest = { - fileContext: { - fileUri: SOME_FILE.uri, - filename: URI.parse(SOME_FILE.uri).path.substring(1), - programmingLanguage: { languageName: 'csharp' }, - leftFileContent: '', - rightFileContent: HELLO_WORLD_IN_CSHARP, - }, - maxResults: 5, - supplementalContexts: [], - predictionTypes: ['COMPLETIONS'], - editorState: { - document: { - relativeFilePath: SOME_FILE.uri, - programmingLanguage: { languageName: 'csharp' }, - text: HELLO_WORLD_IN_CSHARP, - }, - cursorState: { - position: { - line: 0, - character: 0, - }, - }, - }, - } - - sinon.assert.calledOnceWithExactly(service.generateSuggestions, expectedGenerateSuggestionsRequest) - }) }) }) 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 9ea8f6447d..f2b1a2c43d 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 @@ -12,26 +12,24 @@ import { TextDocument, ResponseError, LSPErrorCodes, - WorkspaceFolder, } from '@aws/language-server-runtimes/server-interface' import { autoTrigger, getAutoTriggerType, getNormalizeOsName, triggerType } from './auto-trigger/autoTrigger' import { - CodeWhispererServiceToken, GenerateSuggestionsRequest, GenerateSuggestionsResponse, + getFileContext, Suggestion, SuggestionType, } from '../../shared/codeWhispererService' -import { CodewhispererLanguage, getRuntimeLanguage, getSupportedLanguageId } from '../../shared/languageDetection' +import { getSupportedLanguageId } from '../../shared/languageDetection' import { mergeEditSuggestionsWithFileContext, truncateOverlapWithRightContext } from './mergeRightUtils' import { CodeWhispererSession, SessionManager } from './session/sessionManager' import { CodePercentageTracker } from './codePercentage' import { getCompletionType, getEndPositionForAcceptedSuggestion, getErrorMessage, safeGet } from '../../shared/utils' import { getIdeCategory, makeUserContextObject } from '../../shared/telemetryUtils' -import { fetchSupplementalContext } from '../../shared/supplementalContextUtil/supplementalContextUtil' import { textUtils } from '@aws/lsp-core' import { TelemetryService } from '../../shared/telemetry/telemetryService' -import { AcceptedSuggestionEntry, CodeDiffTracker } from './codeDiffTracker' +import { AcceptedInlineSuggestionEntry, CodeDiffTracker } from './codeDiffTracker' import { AmazonQError, AmazonQServiceConnectionExpiredError, @@ -44,7 +42,6 @@ import { hasConnectionExpired } from '../../shared/utils' import { getOrThrowBaseIAMServiceManager } from '../../shared/amazonQServiceManager/AmazonQIAMServiceManager' import { WorkspaceFolderManager } from '../workspaceContext/workspaceFolderManager' import path = require('path') -import { getRelativePath } from '../workspaceContext/util' import { UserWrittenCodeTracker } from '../../shared/userWrittenCodeTracker' import { RecentEditTracker, RecentEditTrackerDefaultConfig } from './tracker/codeEditTracker' import { CursorTracker } from './tracker/cursorTracker' @@ -56,51 +53,9 @@ import { emitServiceInvocationTelemetry, emitUserTriggerDecisionTelemetry, } from './telemetry' -const { editPredictionAutoTrigger } = require('./auto-trigger/editPredictionAutoTrigger') - -const EMPTY_RESULT = { sessionId: '', items: [] } -export const FILE_URI_CHARS_LIMIT = 1024 -export const FILENAME_CHARS_LIMIT = 1024 -export const CONTEXT_CHARACTERS_LIMIT = 10240 - -// Both clients (token, sigv4) define their own types, this return value needs to match both of them. -const getFileContext = (params: { - textDocument: TextDocument - position: Position - inferredLanguageId: CodewhispererLanguage - workspaceFolder: WorkspaceFolder | null | undefined -}): { - fileUri: string - filename: string - programmingLanguage: { - languageName: CodewhispererLanguage - } - leftFileContent: string - rightFileContent: string -} => { - const left = params.textDocument.getText({ - start: { line: 0, character: 0 }, - end: params.position, - }) - const right = params.textDocument.getText({ - start: params.position, - end: params.textDocument.positionAt(params.textDocument.getText().length), - }) - - const relativeFilePath = params.workspaceFolder - ? getRelativePath(params.workspaceFolder, params.textDocument.uri) - : path.basename(params.textDocument.uri) - - return { - fileUri: params.textDocument.uri.substring(0, FILE_URI_CHARS_LIMIT), - filename: relativeFilePath.substring(0, FILENAME_CHARS_LIMIT), - programmingLanguage: { - languageName: getRuntimeLanguage(params.inferredLanguageId), - }, - leftFileContent: left, - rightFileContent: right, - } -} +import { DocumentChangedListener } from './documentChangedListener' +import { EditCompletionHandler } from './editCompletionHandler' +import { EMPTY_RESULT } from './constants' const mergeSuggestionsWithRightContext = ( rightFileContext: string, @@ -143,23 +98,14 @@ const mergeSuggestionsWithRightContext = ( }) } -interface AcceptedInlineSuggestionEntry extends AcceptedSuggestionEntry { - sessionId: string - requestId: string - languageId: CodewhispererLanguage - customizationArn?: string - completionType: string - triggerType: string - credentialStartUrl?: string | undefined -} - export const CodewhispererServerFactory = (serviceManager: () => AmazonQBaseServiceManager): Server => ({ credentialsProvider, lsp, workspace, telemetry, logging, runtime, sdkInitializator }) => { let lastUserModificationTime: number let timeSinceLastUserModification: number = 0 - const sessionManager = SessionManager.getInstance() + const completionSessionManager = SessionManager.getInstance('COMPLETIONS') + const editSessionManager = SessionManager.getInstance('EDITS') // AmazonQTokenServiceManager and TelemetryService are initialized in `onInitialized` handler to make sure Language Server connection is started let amazonQServiceManager: AmazonQBaseServiceManager @@ -175,6 +121,7 @@ export const CodewhispererServerFactory = let codePercentageTracker: CodePercentageTracker let userWrittenCodeTracker: UserWrittenCodeTracker | undefined let codeDiffTracker: CodeDiffTracker + let editCompletionHandler: EditCompletionHandler // Trackers for monitoring edits and cursor position. const recentEditTracker = RecentEditTracker.getInstance(logging, RecentEditTrackerDefaultConfig) @@ -183,6 +130,8 @@ export const CodewhispererServerFactory = let editsEnabled = false let isOnInlineCompletionHandlerInProgress = false + const documentChangedListener = new DocumentChangedListener() + const onInlineCompletionHandler = async ( params: InlineCompletionWithReferencesParams, token: CancellationToken @@ -200,7 +149,7 @@ export const CodewhispererServerFactory = try { // On every new completion request close current inflight session. - const currentSession = sessionManager.getCurrentSession() + const currentSession = completionSessionManager.getCurrentSession() if (currentSession && currentSession.state == 'REQUESTING' && !params.partialResultToken) { // this REQUESTING state only happens when the session is initialized, which is rare currentSession.discardInflightSessionOnNewInvocation = true @@ -219,12 +168,6 @@ export const CodewhispererServerFactory = ...currentSession.requestContext, fileContext: { ...currentSession.requestContext.fileContext, - leftFileContent: currentSession.requestContext.fileContext.leftFileContent - .slice(-CONTEXT_CHARACTERS_LIMIT) - .replaceAll('\r\n', '\n'), - rightFileContent: currentSession.requestContext.fileContext.rightFileContent - .slice(0, CONTEXT_CHARACTERS_LIMIT) - .replaceAll('\r\n', '\n'), }, nextToken: `${params.partialResultToken}`, }) @@ -269,7 +212,7 @@ export const CodewhispererServerFactory = ? workspaceState.workspaceId : undefined - const previousSession = sessionManager.getPreviousSession() + const previousSession = completionSessionManager.getPreviousSession() const previousDecision = previousSession?.getAggregatedUserTriggerDecision() ?? '' let ideCategory: string | undefined = '' const initializeParams = lsp.getClientInitializeParams() @@ -317,127 +260,29 @@ export const CodewhispererServerFactory = logging ) - if ( - codewhispererAutoTriggerType === 'Classifier' && - !autoTriggerResult.shouldTrigger && - !(editsEnabled && codeWhispererService instanceof CodeWhispererServiceToken) // There is still potentially a Edit trigger without Completion if NEP is enabled (current only BearerTokenClient) - ) { + if (codewhispererAutoTriggerType === 'Classifier' && !autoTriggerResult.shouldTrigger) { return EMPTY_RESULT } } - // Get supplemental context from recent edits if available. - let supplementalContextFromEdits = undefined - - // supplementalContext available only via token authentication - const supplementalContextPromise = - codeWhispererService instanceof CodeWhispererServiceToken - ? fetchSupplementalContext( - textDocument, - params.position, - workspace, - logging, - token, - amazonQServiceManager, - params.openTabFilepaths - ) - : Promise.resolve(undefined) - let requestContext: GenerateSuggestionsRequest = { fileContext, maxResults, } - const supplementalContext = await supplementalContextPromise - // TODO: logging - if (codeWhispererService instanceof CodeWhispererServiceToken) { - const supplementalContextItems = supplementalContext?.supplementalContextItems || [] - requestContext.supplementalContexts = [ - ...supplementalContextItems.map(v => ({ - content: v.content, - filePath: v.filePath, - })), - ] - - if (editsEnabled) { - const predictionTypes: string[][] = [] - - /** - * Manual trigger - should always have 'Completions' - * Auto trigger - * - Classifier - should have 'Completions' when classifier evalualte to true given the editor's states - * - Others - should always have 'Completions' - */ - if ( - !isAutomaticLspTriggerKind || - (isAutomaticLspTriggerKind && codewhispererAutoTriggerType !== 'Classifier') || - (isAutomaticLspTriggerKind && - codewhispererAutoTriggerType === 'Classifier' && - autoTriggerResult?.shouldTrigger) - ) { - predictionTypes.push(['COMPLETIONS']) - } - - const editPredictionAutoTriggerResult = editPredictionAutoTrigger({ - fileContext: fileContext, - lineNum: params.position.line, - char: triggerCharacters, - previousDecision: previousDecision, - cursorHistory: cursorTracker, - recentEdits: recentEditTracker, - }) - - if (editPredictionAutoTriggerResult.shouldTrigger) { - predictionTypes.push(['EDITS']) - } - - if (predictionTypes.length === 0) { - return EMPTY_RESULT - } - - // Step 0: Determine if we have "Recent Edit context" - if (recentEditTracker) { - supplementalContextFromEdits = - await recentEditTracker.generateEditBasedContext(textDocument) - } - - // Step 1: Recent Edits context - const supplementalContextItemsForEdits = - supplementalContextFromEdits?.supplementalContextItems || [] - - requestContext.supplementalContexts.push( - ...supplementalContextItemsForEdits.map(v => ({ - content: v.content, - filePath: v.filePath, - type: 'PreviousEditorState', - metadata: { - previousEditorStateMetadata: { - timeOffset: 1000, - }, - }, - })) - ) + const supplementalContext = await codeWhispererService.constructSupplementalContext( + textDocument, + params.position, + workspace, + recentEditTracker, + logging, + token, + params.openTabFilepaths, + { includeRecentEdits: false } + ) - // Step 2: Prediction type COMPLETION, Edits or both - requestContext.predictionTypes = predictionTypes.flat() - - // Step 3: Current Editor/Cursor state - requestContext.editorState = { - document: { - relativeFilePath: textDocument.uri, - programmingLanguage: { - languageName: requestContext.fileContext.programmingLanguage.languageName, - }, - text: textDocument.getText(), - }, - cursorState: { - position: { - line: params.position.line, - character: params.position.character, - }, - }, - } - } + if (supplementalContext?.items) { + requestContext.supplementalContexts = supplementalContext.items } // Close ACTIVE session and record Discard trigger decision immediately @@ -457,8 +302,8 @@ export const CodewhispererServerFactory = } } // Emit user trigger decision at session close time for active session - sessionManager.discardSession(currentSession) - const streakLength = editsEnabled ? sessionManager.getAndUpdateStreakLength(false) : 0 + completionSessionManager.discardSession(currentSession) + const streakLength = editsEnabled ? completionSessionManager.getAndUpdateStreakLength(false) : 0 await emitUserTriggerDecisionTelemetry( telemetry, telemetryService, @@ -472,28 +317,9 @@ export const CodewhispererServerFactory = ) } - const supplementalMetadata = editsEnabled - ? { - // Merge metadata from edit-based context if available. - contentsLength: - (supplementalContext?.contentsLength || 0) + - (supplementalContextFromEdits?.contentsLength || 0), - latency: Math.max( - supplementalContext?.latency || 0, - supplementalContextFromEdits?.latency || 0 - ), - isUtg: supplementalContext?.isUtg || false, - isProcessTimeout: supplementalContext?.isProcessTimeout || false, - strategy: supplementalContextFromEdits - ? 'recentEdits' - : supplementalContext?.strategy || 'Empty', - supplementalContextItems: [ - ...(supplementalContext?.supplementalContextItems || []), - ...(supplementalContextFromEdits?.supplementalContextItems || []), - ], - } - : supplementalContext - const newSession = sessionManager.createSession({ + const supplementalMetadata = supplementalContext?.supContextData + + const newSession = completionSessionManager.createSession({ document: textDocument, startPosition: params.position, triggerType: isAutomaticLspTriggerKind ? 'AutoTrigger' : 'OnDemand', @@ -517,15 +343,6 @@ export const CodewhispererServerFactory = const generateCompletionReq = { ...requestContext, - fileContext: { - ...requestContext.fileContext, - leftFileContent: requestContext.fileContext.leftFileContent - .slice(-CONTEXT_CHARACTERS_LIMIT) - .replaceAll('\r\n', '\n'), - rightFileContent: requestContext.fileContext.rightFileContent - .slice(0, CONTEXT_CHARACTERS_LIMIT) - .replaceAll('\r\n', '\n'), - }, ...(workspaceId ? { workspaceId: workspaceId } : {}), } try { @@ -570,8 +387,8 @@ export const CodewhispererServerFactory = // Discard previous inflight API response due to new trigger if (session.discardInflightSessionOnNewInvocation) { session.discardInflightSessionOnNewInvocation = false - sessionManager.discardSession(session) - const streakLength = editsEnabled ? sessionManager.getAndUpdateStreakLength(false) : 0 + completionSessionManager.discardSession(session) + const streakLength = editsEnabled ? completionSessionManager.getAndUpdateStreakLength(false) : 0 await emitUserTriggerDecisionTelemetry( telemetry, telemetryService, @@ -594,7 +411,7 @@ export const CodewhispererServerFactory = } // API response was recieved, we can activate session now - sessionManager.activateSession(session) + completionSessionManager.activateSession(session) // Process suggestions to apply Empty or Filter filters const filteredSuggestions = suggestionResponse.suggestions @@ -664,7 +481,7 @@ export const CodewhispererServerFactory = session.suggestionsAfterRightContextMerge.length === 0 && !suggestionResponse.responseContext.nextToken ) { - sessionManager.closeSession(session) + completionSessionManager.closeSession(session) await emitUserTriggerDecisionTelemetry( telemetry, telemetryService, @@ -681,6 +498,7 @@ export const CodewhispererServerFactory = partialResultToken: suggestionResponse.responseContext.nextToken, } } else { + session.hasEditsPending = suggestionResponse.responseContext.nextToken ? true : false return { items: suggestionResponse.suggestions .map(suggestion => { @@ -724,7 +542,7 @@ export const CodewhispererServerFactory = logging.log('Recommendation failure: ' + error) emitServiceInvocationFailure(telemetry, session, error) - sessionManager.closeSession(session) + completionSessionManager.closeSession(session) let translatedError = error @@ -783,6 +601,8 @@ export const CodewhispererServerFactory = removedDiagnostics, } = params + const sessionManager = params.isInlineEdit ? editSessionManager : completionSessionManager + const session = sessionManager.getSessionById(sessionId) if (!session) { logging.log(`ERROR: Session ID ${sessionId} was not found`) @@ -867,8 +687,14 @@ export const CodewhispererServerFactory = if (firstCompletionDisplayLatency) emitPerceivedLatencyTelemetry(telemetry, session) // Always emit user trigger decision at session close - sessionManager.closeSession(session) - const streakLength = editsEnabled ? sessionManager.getAndUpdateStreakLength(isAccepted) : 0 + // Close session unless Edit suggestion was accepted with more pending + const shouldKeepSessionOpen = + session.suggestionType === SuggestionType.EDIT && isAccepted && session.hasEditsPending + + if (!shouldKeepSessionOpen) { + completionSessionManager.closeSession(session) + } + const streakLength = editsEnabled ? completionSessionManager.getAndUpdateStreakLength(isAccepted) : 0 await emitUserTriggerDecisionTelemetry( telemetry, telemetryService, @@ -878,7 +704,8 @@ export const CodewhispererServerFactory = deletedCharactersForEditSuggestion.length, addedDiagnostics, removedDiagnostics, - streakLength + streakLength, + Object.keys(params.completionSessionResult)[0] ) } @@ -954,9 +781,32 @@ export const CodewhispererServerFactory = ) await amazonQServiceManager.addDidChangeConfigurationListener(updateConfiguration) + + editCompletionHandler = new EditCompletionHandler( + logging, + clientParams, + workspace, + amazonQServiceManager, + editSessionManager, + cursorTracker, + recentEditTracker, + rejectedEditTracker, + documentChangedListener, + telemetry, + telemetryService, + credentialsProvider + ) + } + + const onEditCompletion = async ( + param: InlineCompletionWithReferencesParams, + token: CancellationToken + ): Promise => { + return await editCompletionHandler.onEditCompletion(param, token) } lsp.extensions.onInlineCompletionWithReferences(onInlineCompletionHandler) + lsp.extensions.onEditCompletion(onEditCompletion) lsp.extensions.onLogInlineCompletionSessionResults(onLogInlineCompletionSessionResultsHandler) lsp.onInitialized(onInitializedHandler) @@ -976,7 +826,7 @@ export const CodewhispererServerFactory = return } // exclude cases that the document change is from Q suggestions - const currentSession = sessionManager.getCurrentSession() + const currentSession = completionSessionManager.getCurrentSession() if ( !currentSession?.suggestions.some( suggestion => suggestion?.insertText && suggestion.insertText === change.text @@ -992,13 +842,11 @@ export const CodewhispererServerFactory = } lastUserModificationTime = new Date().getTime() + documentChangedListener.onDocumentChanged(p) + editCompletionHandler.documentChanged() + // Process document changes with RecentEditTracker. if (editsEnabled && recentEditTracker) { - logging.log( - `[SERVER] Processing document change with RecentEditTracker: ${p.textDocument.uri}, version: ${textDocument.version}` - ) - logging.log(`[SERVER] Change details: ${p.contentChanges.length} changes`) - await recentEditTracker.handleDocumentChange({ uri: p.textDocument.uri, languageId: textDocument.languageId, diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/constants.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/constants.ts new file mode 100644 index 0000000000..49a33b35de --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/constants.ts @@ -0,0 +1,6 @@ +export const FILE_URI_CHARS_LIMIT = 1024 +export const FILENAME_CHARS_LIMIT = 1024 +export const CONTEXT_CHARACTERS_LIMIT = 10240 +export const EMPTY_RESULT = { sessionId: '', items: [] } +export const EDIT_DEBOUNCE_INTERVAL_MS = 500 +export const EDIT_STALE_RETRY_COUNT = 3 diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/documentChangedListener.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/documentChangedListener.ts new file mode 100644 index 0000000000..302eab1159 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/documentChangedListener.ts @@ -0,0 +1,19 @@ +import { DidChangeTextDocumentParams } from '@aws/language-server-runtimes/protocol' + +export class DocumentChangedListener { + private _lastUserModificationTime: number = 0 + private _timeSinceLastUserModification: number = 0 + get timeSinceLastUserModification(): number { + return this._timeSinceLastUserModification + } + + constructor() {} + + onDocumentChanged(e: DidChangeTextDocumentParams) { + // Record last user modification time for any document + if (this._lastUserModificationTime) { + this._timeSinceLastUserModification = new Date().getTime() - this._lastUserModificationTime + } + this._lastUserModificationTime = new Date().getTime() + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts new file mode 100644 index 0000000000..5a6f8644b1 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts @@ -0,0 +1,439 @@ +import { + CancellationToken, + InitializeParams, + InlineCompletionListWithReferences, + InlineCompletionTriggerKind, + InlineCompletionWithReferencesParams, + LSPErrorCodes, + Range, + ResponseError, + TextDocument, +} from '@aws/language-server-runtimes/protocol' +import { RecentEditTracker } from './tracker/codeEditTracker' +import { CredentialsProvider, Logging, Telemetry, Workspace } from '@aws/language-server-runtimes/server-interface' +import { + CodeWhispererServiceToken, + GenerateSuggestionsRequest, + GenerateSuggestionsResponse, + getFileContext, + SuggestionType, +} from '../../shared/codeWhispererService' +import { CodeWhispererSession, SessionManager } from './session/sessionManager' +import { CursorTracker } from './tracker/cursorTracker' +import { CodewhispererLanguage, getSupportedLanguageId } from '../../shared/languageDetection' +import { WorkspaceFolderManager } from '../workspaceContext/workspaceFolderManager' +import { shouldTriggerEdits } from './trigger' +import { + emitServiceInvocationFailure, + emitServiceInvocationTelemetry, + emitUserTriggerDecisionTelemetry, +} from './telemetry' +import { TelemetryService } from '../../shared/telemetry/telemetryService' +import { mergeEditSuggestionsWithFileContext } from './mergeRightUtils' +import { textUtils } from '@aws/lsp-core' +import { AmazonQBaseServiceManager } from '../../shared/amazonQServiceManager/BaseAmazonQServiceManager' +import { RejectedEditTracker } from './tracker/rejectedEditTracker' +import { getErrorMessage, hasConnectionExpired } from '../../shared/utils' +import { AmazonQError, AmazonQServiceConnectionExpiredError } from '../../shared/amazonQServiceManager/errors' +import { DocumentChangedListener } from './documentChangedListener' +import { EMPTY_RESULT, EDIT_DEBOUNCE_INTERVAL_MS, EDIT_STALE_RETRY_COUNT } from './constants' + +export class EditCompletionHandler { + private readonly editsEnabled: boolean + private debounceTimeout: NodeJS.Timeout | undefined + private isWaiting: boolean = false + private hasDocumentChangedSinceInvocation: boolean = false + + constructor( + readonly logging: Logging, + readonly clientMetadata: InitializeParams, + readonly workspace: Workspace, + readonly amazonQServiceManager: AmazonQBaseServiceManager, + readonly sessionManager: SessionManager, + readonly cursorTracker: CursorTracker, + readonly recentEditsTracker: RecentEditTracker, + readonly rejectedEditTracker: RejectedEditTracker, + readonly documentChangedListener: DocumentChangedListener, + readonly telemetry: Telemetry, + readonly telemetryService: TelemetryService, + readonly credentialsProvider: CredentialsProvider + ) { + this.editsEnabled = + this.clientMetadata.initializationOptions?.aws?.awsClientCapabilities?.textDocument + ?.inlineCompletionWithReferences?.inlineEditSupport ?? false + } + + get codeWhispererService() { + return this.amazonQServiceManager.getCodewhispererService() + } + + /** + * This is a workaround to refresh the debounce timer when user is typing quickly. + * Adding debounce at function call doesnt work because server won't process second request until first request is processed. + * Also as a followup, ideally it should be a message/event publish/subscribe pattern instead of manual invocation like this + */ + documentChanged() { + if (this.debounceTimeout) { + this.logging.info('[NEP] refresh timeout') + this.debounceTimeout.refresh() + } + + if (this.isWaiting) { + this.hasDocumentChangedSinceInvocation = true + } + } + + async onEditCompletion( + params: InlineCompletionWithReferencesParams, + token: CancellationToken + ): Promise { + this.hasDocumentChangedSinceInvocation = false + this.debounceTimeout = undefined + + // On every new completion request close current inflight session. + const currentSession = this.sessionManager.getCurrentSession() + if (currentSession && currentSession.state == 'REQUESTING' && !params.partialResultToken) { + // this REQUESTING state only happens when the session is initialized, which is rare + currentSession.discardInflightSessionOnNewInvocation = true + } + + if (this.cursorTracker) { + this.cursorTracker.trackPosition(params.textDocument.uri, params.position) + } + const textDocument = await this.workspace.getTextDocument(params.textDocument.uri) + if (!textDocument) { + this.logging.warn(`textDocument [${params.textDocument.uri}] not found`) + return EMPTY_RESULT + } + + if (!(this.codeWhispererService instanceof CodeWhispererServiceToken)) { + return EMPTY_RESULT + } + + // request for new session + const inferredLanguageId = getSupportedLanguageId(textDocument) + if (!inferredLanguageId) { + this.logging.log( + `textDocument [${params.textDocument.uri}] with languageId [${textDocument.languageId}] not supported` + ) + return EMPTY_RESULT + } + + if (params.partialResultToken && currentSession) { + // subsequent paginated requests for current session + try { + const suggestionResponse = await this.codeWhispererService.generateSuggestions({ + ...currentSession.requestContext, + nextToken: `${params.partialResultToken}`, + }) + return await this.processSuggestionResponse( + suggestionResponse, + currentSession, + false, + params.context.selectedCompletionInfo?.range + ) + } catch (error) { + return this.handleSuggestionsErrors(error as Error, currentSession) + } + } + + // TODO: telemetry, discarded suggestions + // The other easy way to do this is simply not return any suggestion (which is used when retry > 3) + const invokeWithRetry = async (attempt: number = 0): Promise => { + return new Promise(async resolve => { + this.debounceTimeout = setTimeout(async () => { + try { + this.isWaiting = true + const result = await this._invoke( + params, + token, + textDocument, + inferredLanguageId, + currentSession + ).finally(() => { + this.isWaiting = false + }) + if (this.hasDocumentChangedSinceInvocation) { + if (attempt < EDIT_STALE_RETRY_COUNT) { + this.logging.info( + `EditCompletionHandler - Document changed during execution, retrying (attempt ${attempt + 1})` + ) + this.hasDocumentChangedSinceInvocation = false + const retryResult = await invokeWithRetry(attempt + 1) + resolve(retryResult) + } else { + this.logging.info('EditCompletionHandler - Max retries reached, returning empty result') + resolve(EMPTY_RESULT) + } + } else { + this.logging.info('EditCompletionHandler - No document changes, resolving result') + resolve(result) + } + } finally { + this.debounceTimeout = undefined + } + }, EDIT_DEBOUNCE_INTERVAL_MS) + }) + } + + return invokeWithRetry() + } + + async _invoke( + params: InlineCompletionWithReferencesParams, + token: CancellationToken, + textDocument: TextDocument, + inferredLanguageId: CodewhispererLanguage, + currentSession: CodeWhispererSession | undefined + ): Promise { + // Build request context + const isAutomaticLspTriggerKind = params.context.triggerKind == InlineCompletionTriggerKind.Automatic + const maxResults = isAutomaticLspTriggerKind ? 1 : 5 + const fileContext = getFileContext({ + textDocument, + inferredLanguageId, + position: params.position, + workspaceFolder: this.workspace.getWorkspaceFolder(textDocument.uri), + }) + + const workspaceState = WorkspaceFolderManager.getInstance()?.getWorkspaceState() + const workspaceId = workspaceState?.webSocketClient?.isConnected() ? workspaceState.workspaceId : undefined + + const qEditsTrigger = shouldTriggerEdits( + this.codeWhispererService, + fileContext, + params, + this.cursorTracker, + this.recentEditsTracker, + this.sessionManager, + true + ) + + if (!qEditsTrigger) { + return EMPTY_RESULT + } + + const generateCompletionReq: GenerateSuggestionsRequest = { + fileContext: fileContext, + maxResults: maxResults, + predictionTypes: ['EDITS'], + workspaceId: workspaceId, + } + + if (qEditsTrigger) { + generateCompletionReq.editorState = { + document: { + relativeFilePath: textDocument.uri, + programmingLanguage: { + languageName: generateCompletionReq.fileContext.programmingLanguage.languageName, + }, + text: textDocument.getText(), + }, + cursorState: { + position: { + line: params.position.line, + character: params.position.character, + }, + }, + } + } + + const supplementalContext = await this.codeWhispererService.constructSupplementalContext( + textDocument, + params.position, + this.workspace, + this.recentEditsTracker, + this.logging, + token, + params.openTabFilepaths, + { + includeRecentEdits: true, + } + ) + if (supplementalContext) { + generateCompletionReq.supplementalContexts = supplementalContext.items + } + + // Close ACTIVE session and record Discard trigger decision immediately + if (currentSession && currentSession.state === 'ACTIVE') { + // Emit user trigger decision at session close time for active session + this.sessionManager.discardSession(currentSession) + const streakLength = this.editsEnabled ? this.sessionManager.getAndUpdateStreakLength(false) : 0 + await emitUserTriggerDecisionTelemetry( + this.telemetry, + this.telemetryService, + currentSession, + this.documentChangedListener.timeSinceLastUserModification, + 0, + 0, + [], + [], + streakLength + ) + } + + const newSession = this.sessionManager.createSession({ + document: textDocument, + startPosition: params.position, + triggerType: isAutomaticLspTriggerKind ? 'AutoTrigger' : 'OnDemand', + language: fileContext.programmingLanguage.languageName, + requestContext: generateCompletionReq, + autoTriggerType: undefined, + triggerCharacter: '', + classifierResult: undefined, + classifierThreshold: undefined, + credentialStartUrl: this.credentialsProvider.getConnectionMetadata?.()?.sso?.startUrl ?? undefined, + supplementalMetadata: supplementalContext?.supContextData, + customizationArn: textUtils.undefinedIfEmpty(this.codeWhispererService.customizationArn), + }) + + try { + const suggestionResponse = await this.codeWhispererService.generateSuggestions(generateCompletionReq) + return await this.processSuggestionResponse( + suggestionResponse, + newSession, + true, + params.context.selectedCompletionInfo?.range + ) + } catch (error) { + return this.handleSuggestionsErrors(error as Error, newSession) + } + } + + async processSuggestionResponse( + suggestionResponse: GenerateSuggestionsResponse, + session: CodeWhispererSession, + isNewSession: boolean, + selectionRange?: Range, + textDocument?: TextDocument + ) { + // TODO: we haven't decided how to do these telemetry for Edits suggestions + // codePercentageTracker.countInvocation(session.language) + // userWrittenCodeTracker?.recordUsageCount(session.language) + + if (isNewSession) { + // Populate the session with information from codewhisperer response + session.suggestions = suggestionResponse.suggestions + session.responseContext = suggestionResponse.responseContext + session.codewhispererSessionId = suggestionResponse.responseContext.codewhispererSessionId + session.timeToFirstRecommendation = new Date().getTime() - session.startTime + session.suggestionType = suggestionResponse.suggestionType + } else { + session.suggestions = [...session.suggestions, ...suggestionResponse.suggestions] + } + + // Emit service invocation telemetry for every request sent to backend + emitServiceInvocationTelemetry(this.telemetry, session, suggestionResponse.responseContext.requestId) + + // Discard previous inflight API response due to new trigger + if (session.discardInflightSessionOnNewInvocation) { + session.discardInflightSessionOnNewInvocation = false + this.sessionManager.discardSession(session) + const streakLength = this.editsEnabled ? this.sessionManager.getAndUpdateStreakLength(false) : 0 + await emitUserTriggerDecisionTelemetry( + this.telemetry, + this.telemetryService, + session, + this.documentChangedListener.timeSinceLastUserModification, + 0, + 0, + [], + [], + streakLength + ) + } + + // API response was recieved, we can activate session now + this.sessionManager.activateSession(session) + + // Process suggestions to apply Empty or Filter filters + const filteredSuggestions = suggestionResponse.suggestions + // Empty suggestion filter + .filter(suggestion => { + if (suggestion.content === '') { + session.setSuggestionState(suggestion.itemId, 'Empty') + return false + } + + return true + }) + // References setting filter + .filter(suggestion => { + // State to track whether code with references should be included in + // the response. No locking or concurrency controls, filtering is done + // right before returning and is only guaranteed to be consistent within + // the context of a single response. + const { includeSuggestionsWithCodeReferences } = this.amazonQServiceManager.getConfiguration() + if (includeSuggestionsWithCodeReferences) { + return true + } + + if (suggestion.references == null || suggestion.references.length === 0) { + return true + } + + // Filter out suggestions that have references when includeSuggestionsWithCodeReferences setting is true + session.setSuggestionState(suggestion.itemId, 'Filter') + return false + }) + + return { + items: suggestionResponse.suggestions + .map(suggestion => { + // Check if this suggestion is similar to a previously rejected edit + const isSimilarToRejected = this.rejectedEditTracker.isSimilarToRejected( + suggestion.content, + textDocument?.uri || '' + ) + + if (isSimilarToRejected) { + // Mark as rejected in the session + session.setSuggestionState(suggestion.itemId, 'Reject') + this.logging.debug( + `[EDIT_PREDICTION] Filtered out suggestion similar to previously rejected edit` + ) + // Return empty item that will be filtered out + return { + insertText: '', + isInlineEdit: true, + itemId: suggestion.itemId, + } + } + + return { + insertText: suggestion.content, + isInlineEdit: true, + itemId: suggestion.itemId, + } + }) + .filter(item => item.insertText !== ''), + sessionId: session.id, + partialResultToken: suggestionResponse.responseContext.nextToken, + } + } + + handleSuggestionsErrors(error: Error, session: CodeWhispererSession): InlineCompletionListWithReferences { + this.logging.log('Recommendation failure: ' + error) + emitServiceInvocationFailure(this.telemetry, session, error) + + this.sessionManager.closeSession(session) + + let translatedError = error + + if (hasConnectionExpired(error)) { + translatedError = new AmazonQServiceConnectionExpiredError(getErrorMessage(error)) + } + + if (translatedError instanceof AmazonQError) { + throw new ResponseError( + LSPErrorCodes.RequestFailed, + translatedError.message || 'Error processing suggestion requests', + { + awsErrorCode: translatedError.code, + } + ) + } + + return EMPTY_RESULT + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts index 35568fc6e2..8ac71737cf 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 @@ -6,14 +6,19 @@ import { } from '@aws/language-server-runtimes/server-interface' import { v4 as uuidv4 } from 'uuid' import { CodewhispererAutomatedTriggerType, CodewhispererTriggerType } from '../auto-trigger/autoTrigger' -import { GenerateSuggestionsRequest, ResponseContext, Suggestion } from '../../../shared/codeWhispererService' +import { + GenerateSuggestionsRequest, + ResponseContext, + Suggestion, + SuggestionType, +} from '../../../shared/codeWhispererService' import { CodewhispererLanguage } from '../../../shared/languageDetection' import { CodeWhispererSupplementalContext } from '../../../shared/models/model' import { Logging } from '@aws/language-server-runtimes/server-interface' type SessionState = 'REQUESTING' | 'ACTIVE' | 'CLOSED' | 'ERROR' | 'DISCARD' export type UserDecision = 'Empty' | 'Filter' | 'Discard' | 'Accept' | 'Ignore' | 'Reject' | 'Unseen' -type UserTriggerDecision = 'Accept' | 'Reject' | 'Empty' | 'Discard' +export type UserTriggerDecision = 'Accept' | 'Reject' | 'Empty' | 'Discard' interface CachedSuggestion extends Suggestion { insertText?: string @@ -75,6 +80,8 @@ export class CodeWhispererSession { includeImportsWithSuggestions?: boolean codewhispererSuggestionImportCount: number = 0 suggestionType?: string + hasEditsPending?: boolean = false + // Track the most recent itemId for paginated Edit suggestions constructor(data: SessionData) { this.id = this.generateSessionId() @@ -153,7 +160,11 @@ export class CodeWhispererSession { typeaheadLength?: number ) { // Skip if session results were already recorded for session of session is closed - if (this.state === 'CLOSED' || this.state === 'DISCARD' || this.completionSessionResult) { + if ( + this.state === 'CLOSED' || + this.state === 'DISCARD' || + (this.completionSessionResult && this.suggestionType === SuggestionType.COMPLETION) + ) { return } @@ -240,10 +251,29 @@ export class CodeWhispererSession { } return isEmpty ? 'Empty' : 'Discard' } + + /** + * Determines trigger decision based on the most recent user action. + * Uses the last processed itemId to determine the overall session decision. + */ + getUserTriggerDecision(itemId?: string): UserTriggerDecision | undefined { + // Force Discard trigger decision when session was explicitly discarded by server + if (this.state === 'DISCARD') { + return 'Discard' + } + + if (!itemId) return + + const state = this.getSuggestionState(itemId) + if (state === 'Accept') return 'Accept' + if (state === 'Reject') return 'Reject' + return state === 'Empty' ? 'Empty' : 'Discard' + } } export class SessionManager { - private static _instance?: SessionManager + private static _completionInstance?: SessionManager + private static _editInstance?: SessionManager private currentSession?: CodeWhispererSession private sessionsLog: CodeWhispererSession[] = [] private maxHistorySize = 5 @@ -255,17 +285,18 @@ export class SessionManager { /** * Singleton SessionManager class */ - public static getInstance(): SessionManager { - if (!SessionManager._instance) { - SessionManager._instance = new SessionManager() + public static getInstance(type: 'COMPLETIONS' | 'EDITS' = 'COMPLETIONS'): SessionManager { + if (type === 'EDITS') { + return (SessionManager._editInstance ??= new SessionManager()) } - return SessionManager._instance + return (SessionManager._completionInstance ??= new SessionManager()) } // For unit tests public static reset() { - SessionManager._instance = undefined + SessionManager._completionInstance = undefined + SessionManager._editInstance = undefined } public createSession(data: SessionData): CodeWhispererSession { diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/telemetry.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/telemetry.ts index 4c55ee5b2e..b990ccfceb 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/telemetry.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/telemetry.ts @@ -1,9 +1,10 @@ import { Telemetry, IdeDiagnostic } from '@aws/language-server-runtimes/server-interface' import { AWSError } from 'aws-sdk' -import { CodeWhispererSession } from './session/sessionManager' +import { CodeWhispererSession, UserTriggerDecision } from './session/sessionManager' import { CodeWhispererPerceivedLatencyEvent, CodeWhispererServiceInvocationEvent } from '../../shared/telemetry/types' import { getCompletionType, isAwsError } from '../../shared/utils' import { TelemetryService } from '../../shared/telemetry/telemetryService' +import { SuggestionType } from '../../shared/codeWhispererService' export const emitServiceInvocationTelemetry = ( telemetry: Telemetry, @@ -114,21 +115,30 @@ export const emitUserTriggerDecisionTelemetry = async ( deletedCharsCountForEditSuggestion?: number, addedIdeDiagnostics?: IdeDiagnostic[], removedIdeDiagnostics?: IdeDiagnostic[], - streakLength?: number + streakLength?: number, + itemId?: string ) => { // Prevent reporting user decision if it was already sent if (session.reportedUserDecision) { return } + // Edits show one suggestion sequentially (with pagination), so use latest itemId state; + // Completions show multiple suggestions together, so aggregate all states + const userTriggerDecision = + session.suggestionType === SuggestionType.EDIT + ? session.getUserTriggerDecision(itemId) + : session.getAggregatedUserTriggerDecision() + // Can not emit previous trigger decision if it's not available on the session - if (!session.getAggregatedUserTriggerDecision()) { + if (!userTriggerDecision) { return } await emitAggregatedUserTriggerDecisionTelemetry( telemetryService, session, + userTriggerDecision, timeSinceLastUserModification, addedCharsCountForEditSuggestion, deletedCharsCountForEditSuggestion, @@ -137,12 +147,19 @@ export const emitUserTriggerDecisionTelemetry = async ( streakLength ) - session.reportedUserDecision = true + // Mark telemetry as complete unless Edit suggestion was accepted with more pending + const hasPendingEditTelemetry = + session.suggestionType === SuggestionType.EDIT && session.acceptedSuggestionId && session.hasEditsPending + + if (!hasPendingEditTelemetry) { + session.reportedUserDecision = true + } } export const emitAggregatedUserTriggerDecisionTelemetry = ( telemetryService: TelemetryService, session: CodeWhispererSession, + userTriggerDecision: UserTriggerDecision, timeSinceLastUserModification?: number, addedCharsCountForEditSuggestion?: number, deletedCharsCountForEditSuggestion?: number, @@ -152,6 +169,7 @@ export const emitAggregatedUserTriggerDecisionTelemetry = ( ) => { return telemetryService.emitUserTriggerDecision( session, + userTriggerDecision, timeSinceLastUserModification, addedCharsCountForEditSuggestion, deletedCharsCountForEditSuggestion, diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/trigger.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/trigger.ts new file mode 100644 index 0000000000..06453355a8 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/trigger.ts @@ -0,0 +1,62 @@ +import { CodewhispererLanguage } from '../../shared/languageDetection' +import { SessionManager } from './session/sessionManager' +import { InlineCompletionWithReferencesParams } from '@aws/language-server-runtimes/protocol' +import { editPredictionAutoTrigger } from './auto-trigger/editPredictionAutoTrigger' +import { CursorTracker } from './tracker/cursorTracker' +import { RecentEditTracker } from './tracker/codeEditTracker' +import { CodeWhispererServiceBase, CodeWhispererServiceToken } from '../../shared/codeWhispererService' + +export class NepTrigger {} + +export function shouldTriggerEdits( + service: CodeWhispererServiceBase, + fileContext: { + fileUri: string + filename: string + programmingLanguage: { + languageName: CodewhispererLanguage + } + leftFileContent: string + rightFileContent: string + }, + inlineParams: InlineCompletionWithReferencesParams, + cursorTracker: CursorTracker, + recentEditsTracker: RecentEditTracker, + sessionManager: SessionManager, + editsEnabled: boolean +): NepTrigger | undefined { + if (!editsEnabled) { + return undefined + } + // edits type suggestion is only implemented in bearer token based IDE for now, we dont want to expose such suggestions to other platforms + if (!(service instanceof CodeWhispererServiceToken)) { + return undefined + } + + const documentChangeParams = inlineParams.documentChangeParams + const hasDocChangedParams = + documentChangeParams?.contentChanges && + documentChangeParams.contentChanges.length > 0 && + documentChangeParams.contentChanges[0].text !== undefined + + // if the client does not emit document change for the trigger, use left most character. + const triggerCharacters = hasDocChangedParams + ? documentChangeParams.contentChanges[0].text + : (fileContext.leftFileContent.trim().at(-1) ?? '') + + const previousDecision = sessionManager.getPreviousSession()?.getAggregatedUserTriggerDecision() + const res = editPredictionAutoTrigger({ + fileContext: fileContext, + lineNum: inlineParams.position.line, + char: triggerCharacters, + previousDecision: previousDecision ?? '', + cursorHistory: cursorTracker, + recentEdits: recentEditsTracker, + }) + + if (res.shouldTrigger) { + return new NepTrigger() + } else { + return undefined + } +} diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts index ad31fd15af..b554b5ef23 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts @@ -308,7 +308,7 @@ describe('Telemetry', () => { sinon.assert.calledOnceWithExactly(sessionManagerSpy.closeSession, currentSession) const expectedUserTriggerDecisionMetric = aUserTriggerDecision() - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Empty') }) it('should send Empty User Decision when Codewhisperer returned empty list of suggestions', async () => { @@ -326,7 +326,7 @@ describe('Telemetry', () => { suggestions: [], suggestionsStates: new Map([]), }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Empty') }) it('should send Discard User Decision when all suggestions are filtered out by includeSuggestionsWithCodeReferences setting filter', async () => { @@ -383,7 +383,7 @@ describe('Telemetry', () => { ['cwspr-item-id-3', 'Filter'], ]), }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Discard') }) it('should send Discard User Decision when all suggestions are discarded after right context merge', async () => { @@ -467,7 +467,7 @@ describe('Telemetry', () => { maxResults: 5, }, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Discard') }) }) @@ -571,7 +571,7 @@ describe('Telemetry', () => { codewhispererSessionId: 'cwspr-session-id-1', }, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedEvent, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedEvent, 'Discard') telemetryServiceSpy.resetHistory() @@ -662,7 +662,7 @@ describe('Telemetry', () => { ]), acceptedSuggestionId: 'cwspr-item-id-2', }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Accept') }) it('should emit Reject User Decision event for current active completion session when session results are received without accepted suggestion', async () => { @@ -734,7 +734,7 @@ describe('Telemetry', () => { 'cwspr-item-id-3': { seen: false, accepted: false, discarded: true }, }, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Reject') }) it('should send Discard User Decision when all suggestions have Discard state', async () => { @@ -806,7 +806,7 @@ describe('Telemetry', () => { 'cwspr-item-id-3': { seen: false, accepted: false, discarded: true }, }, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Discard') }) it('should set codewhispererTimeSinceLastDocumentChange as difference between 2 any document changes', async () => { @@ -873,7 +873,7 @@ describe('Telemetry', () => { ]), closeTime: clock.now, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 5678) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Reject', 5678) }) }) @@ -932,7 +932,7 @@ describe('Telemetry', () => { codewhispererSessionId: 'cwspr-session-id-1', }, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Discard') sinon.assert.neverCalledWithMatch( telemetryServiceSpy, { @@ -996,7 +996,7 @@ describe('Telemetry', () => { codewhispererSessionId: 'cwspr-session-id-1', }, }) - sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 0) + sinon.assert.calledWithMatch(telemetryServiceSpy, expectedUserTriggerDecisionMetric, 'Discard') sinon.assert.neverCalledWithMatch( telemetryServiceSpy, { @@ -1451,7 +1451,7 @@ describe('Telemetry', () => { codewhispererSessionId: 'cwspr-session-id-2', }, }), - 0 + 'Discard' ) }) }) @@ -1515,6 +1515,12 @@ describe('Telemetry', () => { 'cwspr-item-id-3': { seen: true, accepted: false, discarded: false }, }, }), + 'Reject', + 0, + 0, + 0, + undefined, + undefined, 0 ) assert.equal(firstSession?.state, 'CLOSED') diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts index 63ad6556e7..187bc41f35 100644 --- a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts @@ -9,6 +9,10 @@ import { Workspace, Logging, SDKInitializator, + TextDocument, + Position, + CancellationToken, + InlineCompletionWithReferencesParams, } from '@aws/language-server-runtimes/server-interface' import { ConfigurationOptions } from 'aws-sdk' import * as sinon from 'sinon' @@ -20,6 +24,9 @@ import { GenerateSuggestionsRequest, GenerateSuggestionsResponse, } from './codeWhispererService' +import { RecentEditTracker } from '../language-server/inline-completion/tracker/codeEditTracker' +import { CodeWhispererSupplementalContext } from './models/model' +import CodeWhispererTokenClient = require('../client/token/codewhispererbearertokenclient') describe('CodeWhispererService', function () { let sandbox: sinon.SinonSandbox @@ -71,6 +78,25 @@ describe('CodeWhispererService', function () { return 'iam' } + async constructSupplementalContext( + document: TextDocument, + position: Position, + workspace: Workspace, + recentEditTracker: RecentEditTracker, + logging: Logging, + cancellationToken: CancellationToken, + opentabs: InlineCompletionWithReferencesParams['openTabFilepaths'], + config: { includeRecentEdits: boolean } + ): Promise< + | { + supContextData: CodeWhispererSupplementalContext + items: CodeWhispererTokenClient.SupplementalContextList + } + | undefined + > { + return undefined + } + // Add public getters for protected properties get testCodeWhispererRegion() { return this.codeWhispererRegion diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts index 6ad476db05..a162ff44e6 100644 --- a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts @@ -7,6 +7,10 @@ import { SDKInitializator, CancellationToken, CancellationTokenSource, + TextDocument, + Position, + WorkspaceFolder, + InlineCompletionWithReferencesParams, } from '@aws/language-server-runtimes/server-interface' import { waitUntil } from '@aws/lsp-core/out/util/timeoutUtils' import { AWSError, ConfigurationOptions, CredentialProviderChain, Credentials } from 'aws-sdk' @@ -26,6 +30,17 @@ import CodeWhispererSigv4Client = require('../client/sigv4/codewhisperersigv4cli import CodeWhispererTokenClient = require('../client/token/codewhispererbearertokenclient') import { getErrorId } from './utils' import { GenerateCompletionsResponse } from '../client/token/codewhispererbearertokenclient' +import { getRelativePath } from '../language-server/workspaceContext/util' +import { CodewhispererLanguage, getRuntimeLanguage } from './languageDetection' +import { RecentEditTracker } from '../language-server/inline-completion/tracker/codeEditTracker' +import { CodeWhispererSupplementalContext } from './models/model' +import { fetchSupplementalContext } from './supplementalContextUtil/supplementalContextUtil' +import * as path from 'path' +import { + CONTEXT_CHARACTERS_LIMIT, + FILE_URI_CHARS_LIMIT, + FILENAME_CHARS_LIMIT, +} from '../language-server/inline-completion/constants' export interface Suggestion extends CodeWhispererTokenClient.Completion, CodeWhispererSigv4Client.Recommendation { itemId: string @@ -56,6 +71,47 @@ export interface GenerateSuggestionsResponse { responseContext: ResponseContext } +export function getFileContext(params: { + textDocument: TextDocument + position: Position + inferredLanguageId: CodewhispererLanguage + workspaceFolder: WorkspaceFolder | null | undefined +}): { + fileUri: string + filename: string + programmingLanguage: { + languageName: CodewhispererLanguage + } + leftFileContent: string + rightFileContent: string +} { + const left = params.textDocument.getText({ + start: { line: 0, character: 0 }, + end: params.position, + }) + const trimmedLeft = left.slice(-CONTEXT_CHARACTERS_LIMIT).replaceAll('\r\n', '\n') + + const right = params.textDocument.getText({ + start: params.position, + end: params.textDocument.positionAt(params.textDocument.getText().length), + }) + const trimmedRight = right.slice(0, CONTEXT_CHARACTERS_LIMIT).replaceAll('\r\n', '\n') + + const relativeFilePath = params.workspaceFolder + ? getRelativePath(params.workspaceFolder, params.textDocument.uri) + : path.basename(params.textDocument.uri) + + return { + fileUri: params.textDocument.uri.substring(0, FILE_URI_CHARS_LIMIT), + filename: relativeFilePath.substring(0, FILENAME_CHARS_LIMIT), + programmingLanguage: { + languageName: getRuntimeLanguage(params.inferredLanguageId), + }, + leftFileContent: trimmedLeft, + rightFileContent: trimmedRight, + } +} + // This abstract class can grow in the future to account for any additional changes across the clients export abstract class CodeWhispererServiceBase { protected readonly codeWhispererRegion @@ -86,6 +142,23 @@ export abstract class CodeWhispererServiceBase { abstract generateSuggestions(request: GenerateSuggestionsRequest): Promise + abstract constructSupplementalContext( + document: TextDocument, + position: Position, + workspace: Workspace, + recentEditTracker: RecentEditTracker, + logging: Logging, + cancellationToken: CancellationToken, + opentabs: InlineCompletionWithReferencesParams['openTabFilepaths'], + config: { includeRecentEdits: boolean } + ): Promise< + | { + supContextData: CodeWhispererSupplementalContext + items: CodeWhispererTokenClient.SupplementalContextList + } + | undefined + > + constructor(codeWhispererRegion: string, codeWhispererEndpoint: string) { this.codeWhispererRegion = codeWhispererRegion this.codeWhispererEndpoint = codeWhispererEndpoint @@ -148,6 +221,25 @@ export class CodeWhispererServiceIAM extends CodeWhispererServiceBase { return 'iam' } + async constructSupplementalContext( + document: TextDocument, + position: Position, + workspace: Workspace, + recentEditTracker: RecentEditTracker, + logging: Logging, + cancellationToken: CancellationToken, + opentabs: InlineCompletionWithReferencesParams['openTabFilepaths'], + config: { includeRecentEdits: boolean } + ): Promise< + | { + supContextData: CodeWhispererSupplementalContext + items: CodeWhispererTokenClient.SupplementalContextList + } + | undefined + > { + return undefined + } + async generateSuggestions(request: GenerateSuggestionsRequest): Promise { // add cancellation check // add error check @@ -244,6 +336,81 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { return 'bearer' } + async constructSupplementalContext( + document: TextDocument, + position: Position, + workspace: Workspace, + recentEditTracker: RecentEditTracker, + logging: Logging, + cancellationToken: CancellationToken, + opentabs: InlineCompletionWithReferencesParams['openTabFilepaths'], + config: { includeRecentEdits: boolean } + ): Promise< + | { + supContextData: CodeWhispererSupplementalContext + items: CodeWhispererTokenClient.SupplementalContextList + } + | undefined + > { + const items: CodeWhispererTokenClient.SupplementalContext[] = [] + + const projectContext = await fetchSupplementalContext( + document, + position, + workspace, + logging, + cancellationToken, + opentabs + ) + if (projectContext) { + items.push( + ...projectContext.supplementalContextItems.map(v => ({ + content: v.content, + filePath: v.filePath, + })) + ) + } + + const recentEditsContext = config.includeRecentEdits + ? await recentEditTracker.generateEditBasedContext(document) + : undefined + if (recentEditsContext) { + items.push( + ...recentEditsContext.supplementalContextItems.map(item => ({ + content: item.content, + filePath: item.filePath, + type: 'PreviousEditorState', + metadata: { + previousEditorStateMetadata: { + timeOffset: 1000, + }, + }, + })) + ) + } + + const merged: CodeWhispererSupplementalContext | undefined = recentEditsContext + ? { + contentsLength: (projectContext?.contentsLength || 0) + (recentEditsContext?.contentsLength || 0), + latency: Math.max(projectContext?.latency || 0, recentEditsContext?.latency || 0), + isUtg: projectContext?.isUtg || false, + isProcessTimeout: projectContext?.isProcessTimeout || false, + strategy: recentEditsContext ? 'recentEdits' : projectContext?.strategy || 'Empty', + supplementalContextItems: [ + ...(projectContext?.supplementalContextItems || []), + ...(recentEditsContext?.supplementalContextItems || []), + ], + } + : projectContext + + return merged + ? { + supContextData: merged, + items: items, + } + : undefined + } + private withProfileArn(request: T): T { if (!this.profileArn) return request @@ -254,27 +421,57 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { // add cancellation check // add error check if (this.customizationArn) request.customizationArn = this.customizationArn - const response = await this.client.generateCompletions(this.withProfileArn(request)).promise() + const beforeApiCall = performance.now() + let recentEditsLogStr = '' + const recentEdits = request.supplementalContexts?.filter(it => it.type === 'PreviousEditorState') + if (recentEdits) { + if (recentEdits.length === 0) { + recentEditsLogStr += `No recent edits` + } else { + recentEditsLogStr += '\n' + for (let i = 0; i < recentEdits.length; i++) { + const e = recentEdits[i] + recentEditsLogStr += `[recentEdits ${i}th]:\n` + recentEditsLogStr += `${e.content}\n` + } + } + } this.logging.info( - `GenerateCompletion response: + `GenerateCompletion request: "endpoint": ${this.codeWhispererEndpoint}, - "requestId": ${response.$response.requestId}, - "responseCompletionCount": ${response.completions?.length ?? 0}, - "responsePredictionCount": ${response.predictions?.length ?? 0}, - "suggestionType": ${request.predictionTypes?.toString() ?? ''}, + "predictionType": ${request.predictionTypes?.toString() ?? 'Not specified (COMPLETIONS)'}, "filename": ${request.fileContext.filename}, "language": ${request.fileContext.programmingLanguage.languageName}, - "supplementalContextLength": ${request.supplementalContexts?.length ?? 0}, + "supplementalContextCount": ${request.supplementalContexts?.length ?? 0}, "request.nextToken": ${request.nextToken}, - "response.nextToken": ${response.nextToken}` + "recentEdits": ${recentEditsLogStr}` ) + const response = await this.client.generateCompletions(this.withProfileArn(request)).promise() + const responseContext = { requestId: response?.$response?.requestId, codewhispererSessionId: response?.$response?.httpResponse?.headers['x-amzn-sessionid'], nextToken: response.nextToken, } - return this.mapCodeWhispererApiResponseToSuggestion(response, responseContext) + + const r = this.mapCodeWhispererApiResponseToSuggestion(response, responseContext) + const firstSuggestionLogstr = r.suggestions.length > 0 ? `\n${r.suggestions[0].content}` : 'No suggestion' + + this.logging.info( + `GenerateCompletion response: + "endpoint": ${this.codeWhispererEndpoint}, + "requestId": ${responseContext.requestId}, + "sessionId": ${responseContext.codewhispererSessionId}, + "responseCompletionCount": ${response.completions?.length ?? 0}, + "responsePredictionCount": ${response.predictions?.length ?? 0}, + "predictionType": ${request.predictionTypes?.toString() ?? ''}, + "latency": ${performance.now() - beforeApiCall}, + "filename": ${request.fileContext.filename}, + "response.nextToken": ${response.nextToken}, + "firstSuggestion": ${firstSuggestionLogstr}` + ) + return r } private mapCodeWhispererApiResponseToSuggestion( diff --git a/server/aws-lsp-codewhisperer/src/shared/constants.ts b/server/aws-lsp-codewhisperer/src/shared/constants.ts index 968f6691ca..cd453a11ba 100644 --- a/server/aws-lsp-codewhisperer/src/shared/constants.ts +++ b/server/aws-lsp-codewhisperer/src/shared/constants.ts @@ -18,6 +18,8 @@ export const AWS_Q_ENDPOINT_URL_ENV_VAR = 'AWS_Q_ENDPOINT_URL' export const Q_CONFIGURATION_SECTION = 'aws.q' export const CODE_WHISPERER_CONFIGURATION_SECTION = 'aws.codeWhisperer' +export const SAGEMAKER_UNIFIED_STUDIO_SERVICE = 'SageMakerUnifiedStudio' + /** * Names of directories relevant to the crash reporting functionality. * diff --git a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.test.ts b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.test.ts index c946f6c6f7..e9e1cc3607 100644 --- a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.test.ts @@ -279,8 +279,7 @@ describe('crossFileContextUtil', function () { document, { line: 0, character: 0 }, features.workspace, - fakeCancellationToken, - amazonQServiceManager + fakeCancellationToken ) assert.deepStrictEqual(result, { supplementalContextItems: [], @@ -308,8 +307,7 @@ describe('crossFileContextUtil', function () { document, { line: 0, character: 0 }, features.workspace, - fakeCancellationToken, - amazonQServiceManager + fakeCancellationToken ) sinon.assert.notCalled(instanceStub) @@ -338,8 +336,7 @@ describe('crossFileContextUtil', function () { document, { line: 0, character: 0 }, features.workspace, - fakeCancellationToken, - amazonQServiceManager + fakeCancellationToken ) assert.deepStrictEqual(result, { supplementalContextItems: [{ content: 'someOtherContet', filePath: '/path/', score: 29.879 }], @@ -364,8 +361,7 @@ describe('crossFileContextUtil', function () { document, { line: 0, character: 0 }, features.workspace, - fakeCancellationToken, - amazonQServiceManager + fakeCancellationToken ) assert.deepStrictEqual(result, { supplementalContextItems: [ diff --git a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.ts b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.ts index d1de00c541..ca5bc40642 100644 --- a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.ts +++ b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.ts @@ -29,7 +29,6 @@ import { LocalProjectContextController } from '../../shared/localProjectContextC import { QueryInlineProjectContextRequestV2 } from 'local-indexing' import { URI } from 'vscode-uri' import { waitUntil } from '@aws/lsp-core/out/util/timeoutUtils' -import { AmazonQBaseServiceManager } from '../amazonQServiceManager/BaseAmazonQServiceManager' type CrossFileSupportedLanguage = | 'java' @@ -67,7 +66,6 @@ export async function fetchSupplementalContextForSrc( position: Position, workspace: Workspace, cancellationToken: CancellationToken, - amazonQServiceManager?: AmazonQBaseServiceManager, openTabFiles?: string[] ): Promise | undefined> { const supplementalContextConfig = getSupplementalContextConfig(document.languageId) @@ -77,14 +75,7 @@ export async function fetchSupplementalContextForSrc( } //TODO: add logic for other strategies once available if (supplementalContextConfig === 'codemap') { - return await codemapContext( - document, - position, - workspace, - cancellationToken, - amazonQServiceManager, - openTabFiles - ) + return await codemapContext(document, position, workspace, cancellationToken, openTabFiles) } return { supplementalContextItems: [], strategy: 'Empty' } } @@ -94,7 +85,6 @@ export async function codemapContext( position: Position, workspace: Workspace, cancellationToken: CancellationToken, - amazonQServiceManager?: AmazonQBaseServiceManager, openTabFiles?: string[] ): Promise | undefined> { let strategy: SupplementalContextStrategy = 'Empty' @@ -108,7 +98,7 @@ export async function codemapContext( const projectContextPromise = waitUntil( async function () { - return await fetchProjectContext(document, position, 'codemap', amazonQServiceManager) + return await fetchProjectContext(document, position, 'codemap') }, { timeout: supplementalContextTimeoutInMs, interval: 5, truthy: false } ) @@ -146,8 +136,7 @@ export async function codemapContext( export async function fetchProjectContext( document: TextDocument, position: Position, - target: 'default' | 'codemap' | 'bm25', - amazonQServiceManager?: AmazonQBaseServiceManager + target: 'default' | 'codemap' | 'bm25' ): Promise { const inputChunk: Chunk = getInputChunk(document, position, crossFileContextConfig.numberOfLinesEachChunk) const fsPath = URI.parse(document.uri).fsPath diff --git a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/supplementalContextUtil.ts b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/supplementalContextUtil.ts index c95aeea1d2..8a5658188b 100644 --- a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/supplementalContextUtil.ts +++ b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/supplementalContextUtil.ts @@ -12,7 +12,6 @@ import { } from '@aws/language-server-runtimes/server-interface' import { crossFileContextConfig, supplementalContextTimeoutInMs } from '../models/constants' import * as os from 'os' -import { AmazonQBaseServiceManager } from '../amazonQServiceManager/BaseAmazonQServiceManager' import { TestIntentDetector } from './unitTestIntentDetection' import { FocalFileResolver } from './focalFileResolution' import * as fs from 'fs' @@ -29,7 +28,6 @@ export async function fetchSupplementalContext( workspace: Workspace, logging: Logging, cancellationToken: CancellationToken, - amazonQServiceManager?: AmazonQBaseServiceManager, openTabFiles?: string[] ): Promise { const timesBeforeFetching = performance.now() @@ -74,7 +72,6 @@ export async function fetchSupplementalContext( position, workspace, cancellationToken, - amazonQServiceManager, openTabFiles ) } diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts index 14df60adb9..04d1cf091c 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts @@ -207,7 +207,7 @@ describe('TelemetryService', () => { telemetryService = new TelemetryService(serviceManagerStub, mockCredentialsProvider, telemetry, logging) - telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession) + telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession, 'Accept') sinon.assert.notCalled(codeWhisperServiceStub.sendTelemetryEvent) }) @@ -222,7 +222,7 @@ describe('TelemetryService', () => { telemetryService.updateOptOutPreference('OPTOUT') - telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession) + telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession, 'Accept') sinon.assert.notCalled(codeWhisperServiceStub.sendTelemetryEvent) }) @@ -238,7 +238,7 @@ describe('TelemetryService', () => { }) // Emitting event with IdC connection - telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession) + telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession, 'Accept') sinon.assert.calledOnce(codeWhisperServiceStub.sendTelemetryEvent) @@ -251,7 +251,7 @@ describe('TelemetryService', () => { codeWhisperServiceStub.sendTelemetryEvent.resetHistory() // Should not emit event anymore with BuilderId - telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession) + telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession, 'Accept') sinon.assert.notCalled(codeWhisperServiceStub.sendTelemetryEvent) }) @@ -290,7 +290,7 @@ describe('TelemetryService', () => { telemetryService.updateEnableTelemetryEventsToDestination(true) telemetryService.updateOptOutPreference('OPTIN') - telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession) + telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession, 'Accept') sinon.assert.calledOnceWithExactly(codeWhisperServiceStub.sendTelemetryEvent, expectedUserTriggerDecisionEvent) sinon.assert.calledOnceWithExactly(telemetry.emitMetric as sinon.SinonStub, { @@ -336,7 +336,7 @@ describe('TelemetryService', () => { telemetryService = new TelemetryService(serviceManagerStub, mockCredentialsProvider, telemetry, logging) telemetryService.updateEnableTelemetryEventsToDestination(false) telemetryService.updateOptOutPreference('OPTOUT') - telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession) + telemetryService.emitUserTriggerDecision(mockSession as CodeWhispererSession, 'Accept') sinon.assert.neverCalledWithMatch(telemetry.emitMetric as sinon.SinonStub, { name: 'codewhisperer_userTriggerDecision', }) diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts index 5f7a806427..6f3ba52028 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts @@ -5,7 +5,10 @@ import { Logging, Telemetry, } from '@aws/language-server-runtimes/server-interface' -import { CodeWhispererSession } from '../../language-server/inline-completion/session/sessionManager' +import { + CodeWhispererSession, + UserTriggerDecision, +} from '../../language-server/inline-completion/session/sessionManager' import { SuggestionState, UserTriggerDecisionEvent, @@ -115,9 +118,11 @@ export class TelemetryService { return service } - private getSuggestionState(session: CodeWhispererSession): SuggestionState { + private getSuggestionState(userTriggerDecision: UserTriggerDecision): SuggestionState { let suggestionState: SuggestionState - switch (session.getAggregatedUserTriggerDecision()) { + // Edits show one suggestion sequentially (with pagination), so use latest itemId state; + // Completions show multiple suggestions together, so aggregate all states + switch (userTriggerDecision) { case 'Accept': suggestionState = 'ACCEPT' break @@ -186,6 +191,7 @@ export class TelemetryService { public emitUserTriggerDecision( session: CodeWhispererSession, + userTriggerDecision: UserTriggerDecision, timeSinceLastUserModification?: number, addedCharacterCount?: number, deletedCharacterCount?: number, @@ -259,7 +265,7 @@ export class TelemetryService { }, completionType: session.suggestions.length > 0 ? getCompletionType(session.suggestions[0]).toUpperCase() : 'LINE', - suggestionState: this.getSuggestionState(session), + suggestionState: this.getSuggestionState(userTriggerDecision), recommendationLatencyMilliseconds: session.firstCompletionDisplayLatency ? session.firstCompletionDisplayLatency : 0, @@ -281,7 +287,8 @@ export class TelemetryService { "acceptedCharacterCount": ${event.acceptedCharacterCount} "addedCharacterCount": ${event.addedCharacterCount} "deletedCharacterCount": ${event.deletedCharacterCount} - "streakLength": ${event.streakLength}`) + "streakLength": ${event.streakLength} + "firstCompletionDisplayLatency: ${event.recommendationLatencyMilliseconds}`) return this.invokeSendTelemetryEvent({ userTriggerDecisionEvent: event, }) diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts index dc6f7705fb..bed82c2939 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts @@ -206,6 +206,7 @@ export enum ChatTelemetryEventName { MCPServerInit = 'amazonq_mcpServerInit', LoadHistory = 'amazonq_loadHistory', CompactHistory = 'amazonq_compactHistory', + CompactNudge = 'amazonq_compactNudge', ChatHistoryAction = 'amazonq_performChatHistoryAction', ExportTab = 'amazonq_exportTab', UiClick = 'ui_click', @@ -231,6 +232,7 @@ export interface ChatTelemetryEventMap { [ChatTelemetryEventName.MCPServerInit]: MCPServerInitializeEvent [ChatTelemetryEventName.LoadHistory]: LoadHistoryEvent [ChatTelemetryEventName.CompactHistory]: CompactHistoryEvent + [ChatTelemetryEventName.CompactNudge]: CompactNudgeEvent [ChatTelemetryEventName.ChatHistoryAction]: ChatHistoryActionEvent [ChatTelemetryEventName.ExportTab]: ExportTabEvent [ChatTelemetryEventName.UiClick]: UiClickEvent @@ -392,6 +394,13 @@ export type LoadHistoryEvent = { export type CompactHistoryEvent = { type: CompactHistoryActionType characters: number + credentialStartUrl?: string + languageServerVersion?: string +} + +export type CompactNudgeEvent = { + characters: number + credentialStartUrl?: string languageServerVersion?: string } diff --git a/server/aws-lsp-codewhisperer/src/shared/utils.test.ts b/server/aws-lsp-codewhisperer/src/shared/utils.test.ts index 5d0062bac2..e83d04bbb3 100644 --- a/server/aws-lsp-codewhisperer/src/shared/utils.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/utils.test.ts @@ -3,14 +3,14 @@ import { ThrottlingException, ThrottlingExceptionReason, } from '@amzn/codewhisperer-streaming' -import { CredentialsProvider, Position } from '@aws/language-server-runtimes/server-interface' +import { CredentialsProvider, Position, InitializeParams } from '@aws/language-server-runtimes/server-interface' import * as assert from 'assert' import { AWSError } from 'aws-sdk' import { expect } from 'chai' import * as sinon from 'sinon' import * as os from 'os' import * as path from 'path' -import { BUILDER_ID_START_URL } from './constants' +import { BUILDER_ID_START_URL, SAGEMAKER_UNIFIED_STUDIO_SERVICE } from './constants' import { getBearerTokenFromProvider, getEndPositionForAcceptedSuggestion, @@ -24,6 +24,7 @@ import { getFileExtensionName, listFilesWithGitignore, getOriginFromClientInfo, + getClientName, sanitizeInput, sanitizeRequestInput, } from './utils' @@ -73,12 +74,86 @@ describe('getBearerTokenFromProvider', () => { }) }) +describe('getClientName', () => { + let originalEnv: string | undefined + + beforeEach(() => { + originalEnv = process.env.SERVICE_NAME + }) + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.SERVICE_NAME = originalEnv + } else { + delete process.env.SERVICE_NAME + } + }) + + it('returns client name from initializationOptions path when SERVICE_NAME is SageMakerUnifiedStudio', () => { + process.env.SERVICE_NAME = SAGEMAKER_UNIFIED_STUDIO_SERVICE + const lspParams = { + initializationOptions: { + aws: { + clientInfo: { + name: 'AmazonQ-For-SMUS-CE-1.0.0', + }, + }, + }, + clientInfo: { + name: 'VSCode-Extension', + }, + } as InitializeParams + + const result = getClientName(lspParams) + assert.strictEqual(result, 'AmazonQ-For-SMUS-CE-1.0.0') + }) + + it('returns client name from clientInfo path when SERVICE_NAME is not SageMakerUnifiedStudio', () => { + process.env.SERVICE_NAME = 'SomeOtherService' + const lspParams = { + initializationOptions: { + aws: { + clientInfo: { + name: 'AmazonQ-For-SMUS-CE-1.0.0', + }, + }, + }, + clientInfo: { + name: 'VSCode-Extension', + }, + } as InitializeParams + + const result = getClientName(lspParams) + assert.strictEqual(result, 'VSCode-Extension') + }) + + it('returns undefined when lspParams is undefined', () => { + const result = getClientName(undefined) + assert.strictEqual(result, undefined) + }) +}) + describe('getOriginFromClientInfo', () => { - it('returns MD_IDE for SMUS client name', () => { + it('returns MD_IDE for SMUS-IDE client name', () => { const result = getOriginFromClientInfo('AmazonQ-For-SMUS-IDE-1.0.0') assert.strictEqual(result, 'MD_IDE') }) + it('returns MD_IDE for SMUS-CE client name', () => { + const result = getOriginFromClientInfo('AmazonQ-For-SMUS-CE-1.0.0') + assert.strictEqual(result, 'MD_IDE') + }) + + it('returns MD_IDE for client names starting with SMUS-IDE prefix', () => { + const result = getOriginFromClientInfo('AmazonQ-For-SMUS-IDE') + assert.strictEqual(result, 'MD_IDE') + }) + + it('returns MD_IDE for client names starting with SMUS-CE prefix', () => { + const result = getOriginFromClientInfo('AmazonQ-For-SMUS-CE') + assert.strictEqual(result, 'MD_IDE') + }) + it('returns IDE for non-SMUS client name', () => { const result = getOriginFromClientInfo('VSCode-Extension') assert.strictEqual(result, 'IDE') @@ -93,6 +168,11 @@ describe('getOriginFromClientInfo', () => { const result = getOriginFromClientInfo('') assert.strictEqual(result, 'IDE') }) + + it('returns IDE for client names that do not match SMUS patterns', () => { + const result = getOriginFromClientInfo('AmazonQ-For-Other-IDE') + assert.strictEqual(result, 'IDE') + }) }) describe('getSsoConnectionType', () => { diff --git a/server/aws-lsp-codewhisperer/src/shared/utils.ts b/server/aws-lsp-codewhisperer/src/shared/utils.ts index b896540f72..e178de4a51 100644 --- a/server/aws-lsp-codewhisperer/src/shared/utils.ts +++ b/server/aws-lsp-codewhisperer/src/shared/utils.ts @@ -14,6 +14,7 @@ import { crashMonitoringDirName, driveLetterRegex, MISSING_BEARER_TOKEN_ERROR, + SAGEMAKER_UNIFIED_STUDIO_SERVICE, } from './constants' import { CodeWhispererStreamingServiceException, @@ -373,8 +374,14 @@ export function getBearerTokenFromProvider(credentialsProvider: CredentialsProvi return credentials.token } +export function getClientName(lspParams: InitializeParams | undefined): string | undefined { + return process.env.SERVICE_NAME === SAGEMAKER_UNIFIED_STUDIO_SERVICE + ? lspParams?.initializationOptions?.aws?.clientInfo?.name + : lspParams?.clientInfo?.name +} + export function getOriginFromClientInfo(clientName: string | undefined): Origin { - if (clientName?.startsWith('AmazonQ-For-SMUS-IDE')) { + if (clientName?.startsWith('AmazonQ-For-SMUS-IDE') || clientName?.startsWith('AmazonQ-For-SMUS-CE')) { return 'MD_IDE' } return 'IDE' diff --git a/server/aws-lsp-identity/package.json b/server/aws-lsp-identity/package.json index 929bee5583..e8cb3b2c8a 100644 --- a/server/aws-lsp-identity/package.json +++ b/server/aws-lsp-identity/package.json @@ -26,7 +26,7 @@ "dependencies": { "@aws-sdk/client-sso-oidc": "^3.616.0", "@aws-sdk/token-providers": "^3.744.0", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.12", "@smithy/node-http-handler": "^3.2.5", "@smithy/shared-ini-file-loader": "^4.0.1", diff --git a/server/aws-lsp-json/package.json b/server/aws-lsp-json/package.json index df4d04e39a..298f3820f0 100644 --- a/server/aws-lsp-json/package.json +++ b/server/aws-lsp-json/package.json @@ -26,7 +26,7 @@ "prepack": "shx cp ../../LICENSE ../../NOTICE ../../SECURITY.md ." }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.13", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8" diff --git a/server/aws-lsp-notification/package.json b/server/aws-lsp-notification/package.json index 6cc3037232..a9e3cf9d4c 100644 --- a/server/aws-lsp-notification/package.json +++ b/server/aws-lsp-notification/package.json @@ -22,7 +22,7 @@ "coverage:report": "c8 report --reporter=html --reporter=text" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.12", "vscode-languageserver": "^9.0.1" }, diff --git a/server/aws-lsp-partiql/package.json b/server/aws-lsp-partiql/package.json index 620b3ba16b..9ff685cfdc 100644 --- a/server/aws-lsp-partiql/package.json +++ b/server/aws-lsp-partiql/package.json @@ -24,7 +24,7 @@ "out" ], "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "antlr4-c3": "3.4.2", "antlr4ng": "3.0.14", "web-tree-sitter": "0.22.6" diff --git a/server/aws-lsp-s3/package.json b/server/aws-lsp-s3/package.json index e971b6af08..caa7801b5f 100644 --- a/server/aws-lsp-s3/package.json +++ b/server/aws-lsp-s3/package.json @@ -9,7 +9,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.623.0", "@aws-sdk/types": "^3.734.0", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.12", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8" diff --git a/server/aws-lsp-yaml/package.json b/server/aws-lsp-yaml/package.json index 2231bd884a..58fd5e591d 100644 --- a/server/aws-lsp-yaml/package.json +++ b/server/aws-lsp-yaml/package.json @@ -26,7 +26,7 @@ "postinstall": "node patchYamlPackage.js" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "@aws/lsp-core": "^0.0.13", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8", diff --git a/server/device-sso-auth-lsp/package.json b/server/device-sso-auth-lsp/package.json index 529b59ad6b..573170a3a9 100644 --- a/server/device-sso-auth-lsp/package.json +++ b/server/device-sso-auth-lsp/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "vscode-languageserver": "^9.0.1" }, "devDependencies": { diff --git a/server/hello-world-lsp/package.json b/server/hello-world-lsp/package.json index e24f1d0ff7..827509803e 100644 --- a/server/hello-world-lsp/package.json +++ b/server/hello-world-lsp/package.json @@ -13,7 +13,7 @@ "coverage:report": "c8 report --reporter=html --reporter=text" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.123", "vscode-languageserver": "^9.0.1" }, "devDependencies": {