= {
- 'us-east-1': modelSelection,
- 'eu-central-1': modelSelection,
-}
-
export const getModelSelectionChatItem = (modelName: string): ChatItem => ({
type: ChatItemType.DIRECTIVE,
contentHorizontalAlignment: 'center',
diff --git a/client/vscode/package.json b/client/vscode/package.json
index 975a395d66..6b936bfd3f 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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@types/uuid": "^9.0.8",
"@types/vscode": "^1.98.0",
"jose": "^5.2.4",
diff --git a/core/aws-lsp-core/CHANGELOG.md b/core/aws-lsp-core/CHANGELOG.md
index 9e2e616f55..7b08b5c071 100644
--- a/core/aws-lsp-core/CHANGELOG.md
+++ b/core/aws-lsp-core/CHANGELOG.md
@@ -1,5 +1,24 @@
# Changelog
+## [0.0.15](https://github.com/aws/language-servers/compare/lsp-core/v0.0.14...lsp-core/v0.0.15) (2025-09-09)
+
+
+### Features
+
+* add support for getSupplementalContext LSP API ([#2212](https://github.com/aws/language-servers/issues/2212)) ([2ddcae7](https://github.com/aws/language-servers/commit/2ddcae7a4fac6b89cbc9784911959743ea0a6d11))
+
+## [0.0.14](https://github.com/aws/language-servers/compare/lsp-core/v0.0.13...lsp-core/v0.0.14) (2025-08-19)
+
+
+### Features
+
+* **amazonq:** added mcp admin level configuration with GetProfile ([#2000](https://github.com/aws/language-servers/issues/2000)) ([fd6e9a8](https://github.com/aws/language-servers/commit/fd6e9a829c6229c276de5340dffce52b426a864d))
+
+
+### Bug Fixes
+
+* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289))
+
## [0.0.13](https://github.com/aws/language-servers/compare/lsp-core/v0.0.12...lsp-core/v0.0.13) (2025-08-04)
diff --git a/core/aws-lsp-core/package.json b/core/aws-lsp-core/package.json
index aeff582d34..2876679e39 100644
--- a/core/aws-lsp-core/package.json
+++ b/core/aws-lsp-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@aws/lsp-core",
- "version": "0.0.13",
+ "version": "0.0.15",
"description": "Core library, contains common code and utilities",
"main": "out/index.js",
"repository": {
@@ -28,7 +28,7 @@
"prepack": "shx cp ../../LICENSE ../../NOTICE ../../SECURITY.md ."
},
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@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 5431142f5c..7018052547 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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-core": "*"
},
"devDependencies": {
diff --git a/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts b/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts
index 897ee02071..c5a8046bd5 100644
--- a/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts
+++ b/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts
@@ -169,11 +169,11 @@ describe('Q Agentic Chat Server Integration Tests', async () => {
expect(decryptedResult.additionalMessages).to.be.an('array')
const fsReadMessage = decryptedResult.additionalMessages?.find(
- msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 file read'
+ msg => msg.type === 'tool' && msg.header?.body === '1 file read'
)
expect(fsReadMessage).to.exist
const expectedPath = path.join(rootPath, 'test.py')
- const actualPaths = fsReadMessage?.fileList?.filePaths?.map(normalizePath) || []
+ const actualPaths = fsReadMessage?.header?.fileList?.filePaths?.map(normalizePath) || []
expect(actualPaths).to.include.members([normalizePath(expectedPath)])
expect(fsReadMessage?.messageId?.startsWith('tooluse_')).to.be.true
})
@@ -191,10 +191,10 @@ describe('Q Agentic Chat Server Integration Tests', async () => {
expect(decryptedResult.additionalMessages).to.be.an('array')
const listDirectoryMessage = decryptedResult.additionalMessages?.find(
- msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 directory listed'
+ msg => msg.type === 'tool' && msg.header?.body === '1 directory listed'
)
expect(listDirectoryMessage).to.exist
- const actualPaths = listDirectoryMessage?.fileList?.filePaths?.map(normalizePath) || []
+ const actualPaths = listDirectoryMessage?.header?.fileList?.filePaths?.map(normalizePath) || []
expect(actualPaths).to.include.members([normalizePath(rootPath)])
expect(listDirectoryMessage?.messageId?.startsWith('tooluse_')).to.be.true
})
@@ -371,11 +371,12 @@ describe('Q Agentic Chat Server Integration Tests', async () => {
expect(decryptedResult.additionalMessages).to.be.an('array')
const fileSearchMessage = decryptedResult.additionalMessages?.find(
- msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 directory searched'
+ msg => msg.type === 'tool' && msg.header?.body === 'Searched for "test" in '
)
expect(fileSearchMessage).to.exist
expect(fileSearchMessage?.messageId?.startsWith('tooluse_')).to.be.true
- const actualPaths = fileSearchMessage?.fileList?.filePaths?.map(normalizePath) || []
+ expect(fileSearchMessage?.header?.status?.text).to.equal('3 results found')
+ const actualPaths = fileSearchMessage?.header?.fileList?.filePaths?.map(normalizePath) || []
expect(actualPaths).to.include.members([normalizePath(rootPath)])
})
})
diff --git a/package-lock.json b/package-lock.json
index b42f206c44..c137a9d38c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,7 @@
"integration-tests/*"
],
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.116",
+ "@aws/language-server-runtimes": "^0.2.124",
"@smithy/types": "4.2.0",
"clean": "^4.0.2",
"typescript": "^5.8.2"
@@ -48,7 +48,7 @@
"name": "@aws/lsp-antlr4-runtimes",
"version": "0.0.1",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-json": "*"
},
"devDependencies": {
@@ -148,7 +148,7 @@
"name": "@aws/lsp-notification-runtimes",
"version": "0.1.0",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-notification": "^0.0.1"
}
},
@@ -156,7 +156,7 @@
"name": "@aws/lsp-partiql-runtimes",
"version": "0.0.1",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.120",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-partiql": "^0.0.5"
},
"devDependencies": {
@@ -181,7 +181,7 @@
"name": "@aws/lsp-s3-runtimes",
"version": "0.0.1",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@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.123"
+ "@aws/language-server-runtimes": "^0.2.128"
},
"devDependencies": {
"@types/chai": "^4.3.5",
@@ -251,13 +251,13 @@
},
"chat-client": {
"name": "@aws/chat-client",
- "version": "0.1.32",
+ "version": "0.1.35",
"license": "Apache-2.0",
"dependencies": {
"@aws/chat-client-ui-types": "^0.1.56",
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/language-server-runtimes-types": "^0.1.50",
- "@aws/mynah-ui": "^4.36.4"
+ "@aws/mynah-ui": "^4.36.5"
},
"devDependencies": {
"@types/jsdom": "^21.1.6",
@@ -280,7 +280,7 @@
"@aws-sdk/credential-providers": "^3.731.1",
"@aws-sdk/types": "^3.734.0",
"@aws/chat-client-ui-types": "^0.1.56",
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@types/uuid": "^9.0.8",
"@types/vscode": "^1.98.0",
"jose": "^5.2.4",
@@ -293,10 +293,10 @@
},
"core/aws-lsp-core": {
"name": "@aws/lsp-core",
- "version": "0.0.13",
+ "version": "0.0.15",
"license": "Apache-2.0",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-core": "*"
},
"devDependencies": {
@@ -4036,12 +4036,12 @@
"link": true
},
"node_modules/@aws/language-server-runtimes": {
- "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==",
+ "version": "0.2.128",
+ "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.128.tgz",
+ "integrity": "sha512-C666VAvY2PQ8CQkDzjL/+N9rfcFzY6vuGe733drMwwRVHt8On0B0PQPjy31ZjxHUUcjVp78Nb9vmSUEVBfxGTQ==",
"license": "Apache-2.0",
"dependencies": {
- "@aws/language-server-runtimes-types": "^0.1.55",
+ "@aws/language-server-runtimes-types": "^0.1.56",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.200.0",
"@opentelemetry/core": "^2.0.0",
@@ -4068,9 +4068,9 @@
}
},
"node_modules/@aws/language-server-runtimes-types": {
- "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==",
+ "version": "0.1.56",
+ "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.1.56.tgz",
+ "integrity": "sha512-Md/L750JShCHUsCQUJva51Ofkn/GDBEX8PpZnWUIVqkpddDR00SLQS2smNf4UHtKNJ2fefsfks/Kqfuatjkjvg==",
"license": "Apache-2.0",
"dependencies": {
"vscode-languageserver-textdocument": "^1.0.12",
@@ -4204,9 +4204,9 @@
"link": true
},
"node_modules/@aws/mynah-ui": {
- "version": "4.36.4",
- "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.36.4.tgz",
- "integrity": "sha512-vGW4wlNindpr2Ep9x3iuKbrZTXe5KrE8vWpg15DjkN3qK42KMuMEQ67Pqtfgl5EseNYC1ukZm4HIQIMmt+vevA==",
+ "version": "4.36.5",
+ "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.36.5.tgz",
+ "integrity": "sha512-HMXqvSpZT84mpY67ChzRDrd73Y9AFZVZ8RcOJ/rNWIXR44uryfNFg2nrvoP4GSn2P+kU8WIPGChHGmyX9N0UgA==",
"hasInstallScript": true,
"license": "Apache License 2.0",
"dependencies": {
@@ -9138,6 +9138,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/escape-html": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@types/escape-html/-/escape-html-1.0.4.tgz",
+ "integrity": "sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -28605,11 +28612,11 @@
},
"server/aws-lsp-antlr4": {
"name": "@aws/lsp-antlr4",
- "version": "0.1.17",
+ "version": "0.1.19",
"license": "Apache-2.0",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
- "@aws/lsp-core": "^0.0.13"
+ "@aws/language-server-runtimes": "^0.2.128",
+ "@aws/lsp-core": "^0.0.15"
},
"devDependencies": {
"@babel/plugin-transform-modules-commonjs": "^7.24.1",
@@ -28650,7 +28657,7 @@
"name": "@aws/lsp-buildspec",
"version": "0.0.1",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-json": "*",
"@aws/lsp-yaml": "*",
"vscode-languageserver": "^9.0.1",
@@ -28661,7 +28668,7 @@
"name": "@aws/lsp-cloudformation",
"version": "0.0.1",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-core": "*",
"@aws/lsp-json": "*",
"vscode-languageserver": "^9.0.1",
@@ -28670,7 +28677,7 @@
},
"server/aws-lsp-codewhisperer": {
"name": "@aws/lsp-codewhisperer",
- "version": "0.0.73",
+ "version": "0.0.78",
"bundleDependencies": [
"@amzn/codewhisperer-streaming",
"@amzn/amazon-q-developer-streaming-client"
@@ -28683,8 +28690,8 @@
"@aws-sdk/util-arn-parser": "^3.723.0",
"@aws-sdk/util-retry": "^3.374.0",
"@aws/chat-client-ui-types": "^0.1.56",
- "@aws/language-server-runtimes": "^0.2.123",
- "@aws/lsp-core": "^0.0.13",
+ "@aws/language-server-runtimes": "^0.2.128",
+ "@aws/lsp-core": "^0.0.15",
"@modelcontextprotocol/sdk": "^1.15.0",
"@smithy/node-http-handler": "^2.5.0",
"adm-zip": "^0.5.10",
@@ -28710,6 +28717,7 @@
"picomatch": "^4.0.2",
"shlex": "2.1.2",
"typescript-collections": "^1.3.3",
+ "unescape-html": "^1.1.0",
"uuid": "^11.0.5",
"vscode-uri": "^3.1.0",
"ws": "^8.18.0",
@@ -28721,6 +28729,7 @@
"@types/archiver": "^6.0.2",
"@types/diff": "^7.0.2",
"@types/encoding-japanese": "^2.2.1",
+ "@types/escape-html": "^1.0.4",
"@types/ignore-walk": "^4.0.3",
"@types/local-indexing": "file:./types/types-local-indexing-1.1.0.tgz",
"@types/lokijs": "^1.5.14",
@@ -28823,7 +28832,7 @@
"dependencies": {
"@aws-sdk/client-sso-oidc": "^3.616.0",
"@aws-sdk/token-providers": "^3.744.0",
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-core": "^0.0.12",
"@smithy/node-http-handler": "^3.2.5",
"@smithy/shared-ini-file-loader": "^4.0.1",
@@ -28885,11 +28894,11 @@
},
"server/aws-lsp-json": {
"name": "@aws/lsp-json",
- "version": "0.1.17",
+ "version": "0.1.19",
"license": "Apache-2.0",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
- "@aws/lsp-core": "^0.0.13",
+ "@aws/language-server-runtimes": "^0.2.128",
+ "@aws/lsp-core": "^0.0.15",
"vscode-languageserver": "^9.0.1",
"vscode-languageserver-textdocument": "^1.0.8"
},
@@ -28905,7 +28914,7 @@
"version": "0.0.1",
"license": "Apache-2.0",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-core": "^0.0.12",
"vscode-languageserver": "^9.0.1"
},
@@ -28963,10 +28972,10 @@
},
"server/aws-lsp-partiql": {
"name": "@aws/lsp-partiql",
- "version": "0.0.16",
+ "version": "0.0.18",
"license": "Apache-2.0",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"antlr4-c3": "3.4.2",
"antlr4ng": "3.0.14",
"web-tree-sitter": "0.22.6"
@@ -28988,7 +28997,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.623.0",
"@aws-sdk/types": "^3.734.0",
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-core": "^0.0.12",
"vscode-languageserver": "^9.0.1",
"vscode-languageserver-textdocument": "^1.0.8"
@@ -29015,12 +29024,12 @@
},
"server/aws-lsp-yaml": {
"name": "@aws/lsp-yaml",
- "version": "0.1.17",
+ "version": "0.1.19",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
- "@aws/lsp-core": "^0.0.13",
+ "@aws/language-server-runtimes": "^0.2.128",
+ "@aws/lsp-core": "^0.0.15",
"vscode-languageserver": "^9.0.1",
"vscode-languageserver-textdocument": "^1.0.8",
"yaml-language-server": "1.13.0"
@@ -29033,7 +29042,7 @@
"name": "@amzn/device-sso-auth-lsp",
"version": "0.0.1",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"vscode-languageserver": "^9.0.1"
},
"devDependencies": {
@@ -29044,7 +29053,7 @@
"name": "@aws/hello-world-lsp",
"version": "0.0.1",
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"vscode-languageserver": "^9.0.1"
},
"devDependencies": {
diff --git a/package.json b/package.json
index 82468102ab..179f74be42 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,7 @@
"ci:generate:agentic:attribution": "ts-node ./script/prepare-agentic-attribution-dependencies.ts && ./script/generate-agentic-attribution.sh && git restore package.json"
},
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.116",
+ "@aws/language-server-runtimes": "^0.2.124",
"@smithy/types": "4.2.0",
"clean": "^4.0.2",
"typescript": "^5.8.2"
diff --git a/server/aws-lsp-antlr4/CHANGELOG.md b/server/aws-lsp-antlr4/CHANGELOG.md
index bde43f2c3c..64cab4eec1 100644
--- a/server/aws-lsp-antlr4/CHANGELOG.md
+++ b/server/aws-lsp-antlr4/CHANGELOG.md
@@ -1,5 +1,33 @@
# Changelog
+## [0.1.19](https://github.com/aws/language-servers/compare/lsp-antlr4/v0.1.18...lsp-antlr4/v0.1.19) (2025-09-09)
+
+
+### Features
+
+* add support for getSupplementalContext LSP API ([#2212](https://github.com/aws/language-servers/issues/2212)) ([2ddcae7](https://github.com/aws/language-servers/commit/2ddcae7a4fac6b89cbc9784911959743ea0a6d11))
+
+
+### Dependencies
+
+* The following workspace dependencies were updated
+ * dependencies
+ * @aws/lsp-core bumped from ^0.0.14 to ^0.0.15
+
+## [0.1.18](https://github.com/aws/language-servers/compare/lsp-antlr4/v0.1.17...lsp-antlr4/v0.1.18) (2025-08-19)
+
+
+### Bug Fixes
+
+* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289))
+
+
+### Dependencies
+
+* The following workspace dependencies were updated
+ * dependencies
+ * @aws/lsp-core bumped from ^0.0.13 to ^0.0.14
+
## [0.1.17](https://github.com/aws/language-servers/compare/lsp-antlr4/v0.1.16...lsp-antlr4/v0.1.17) (2025-08-04)
diff --git a/server/aws-lsp-antlr4/package.json b/server/aws-lsp-antlr4/package.json
index 9d6c925b40..f9a4ecdeba 100644
--- a/server/aws-lsp-antlr4/package.json
+++ b/server/aws-lsp-antlr4/package.json
@@ -1,6 +1,6 @@
{
"name": "@aws/lsp-antlr4",
- "version": "0.1.17",
+ "version": "0.1.19",
"description": "ANTLR4 language server",
"main": "out/index.js",
"repository": {
@@ -28,8 +28,8 @@
"clean": "rm -rf node_modules"
},
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
- "@aws/lsp-core": "^0.0.13"
+ "@aws/language-server-runtimes": "^0.2.128",
+ "@aws/lsp-core": "^0.0.15"
},
"peerDependencies": {
"antlr4-c3": ">=3.4 < 4",
diff --git a/server/aws-lsp-buildspec/package.json b/server/aws-lsp-buildspec/package.json
index f59edb5549..f0184e0ff8 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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@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 bfc8ebd7e5..89b56ede3f 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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@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 1b2f1011df..67d05de3c1 100644
--- a/server/aws-lsp-codewhisperer/CHANGELOG.md
+++ b/server/aws-lsp-codewhisperer/CHANGELOG.md
@@ -1,5 +1,128 @@
# Changelog
+## [0.0.78](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.77...lsp-codewhisperer/v0.0.78) (2025-09-09)
+
+
+### Features
+
+* add custom_transformation folder support to artifact.zip ([#2201](https://github.com/aws/language-servers/issues/2201)) ([1222905](https://github.com/aws/language-servers/commit/12229059421b773d3e99d28809fdff4abf242b26))
+* add support for getSupplementalContext LSP API ([#2212](https://github.com/aws/language-servers/issues/2212)) ([2ddcae7](https://github.com/aws/language-servers/commit/2ddcae7a4fac6b89cbc9784911959743ea0a6d11))
+* **amazonq:** default to diff-based scans ([#2195](https://github.com/aws/language-servers/issues/2195)) ([da4c3db](https://github.com/aws/language-servers/commit/da4c3db5329bd50cfe249bf8c1d59afa9bcb0157))
+* model selection for code review tool ([#2196](https://github.com/aws/language-servers/issues/2196)) ([34bc9bd](https://github.com/aws/language-servers/commit/34bc9bd1d3433bbb1d903eb0f212b10709ea8412))
+
+
+### Bug Fixes
+
+* **amazonq:** add IntelliSense autotriggerType ([#2199](https://github.com/aws/language-servers/issues/2199)) ([013aa59](https://github.com/aws/language-servers/commit/013aa5913c242451a91ed36b0dcf961a3f8ec697))
+* **amazonq:** fix to correct the client for getProfile request ([#2211](https://github.com/aws/language-servers/issues/2211)) ([8bde8c9](https://github.com/aws/language-servers/commit/8bde8c97e1e3bcd67d9816a3385c50c7765c3b2f))
+* **amazonq:** fix to update MCP servers list when last server is removed from agent config ([#2206](https://github.com/aws/language-servers/issues/2206)) ([512502a](https://github.com/aws/language-servers/commit/512502af947dcfed9288be2f67fc58affd4445fe))
+* **amazonq:** update to the agent config format to bring parity with Q CLI ([#2202](https://github.com/aws/language-servers/issues/2202)) ([698d06c](https://github.com/aws/language-servers/commit/698d06c643897da6ca37a49e6544b150b72678a3))
+* potential xss issue reported in `mynah-ui` ([#2209](https://github.com/aws/language-servers/issues/2209)) ([cf585cd](https://github.com/aws/language-servers/commit/cf585cd400dab6274f8220139ae94287c0d96824))
+
+
+### Dependencies
+
+* The following workspace dependencies were updated
+ * dependencies
+ * @aws/lsp-core bumped from ^0.0.14 to ^0.0.15
+
+## [0.0.77](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.76...lsp-codewhisperer/v0.0.77) (2025-09-02)
+
+
+### Features
+
+* passing suggestionTypes and pluginVersion/lspVersion to STE ([#2180](https://github.com/aws/language-servers/issues/2180)) ([66742ad](https://github.com/aws/language-servers/commit/66742adfc44f33efbd8dd33b803000e08241e5ce))
+
+
+### Bug Fixes
+
+* auto trigger should only respect previous decisions in the past 2mins ([#2189](https://github.com/aws/language-servers/issues/2189)) ([852b21b](https://github.com/aws/language-servers/commit/852b21b66f793102c52e35c2baec07a772e5134a))
+* compact UI is not updated correctly when multiple nudges are displayed ([#2192](https://github.com/aws/language-servers/issues/2192)) ([ef7d793](https://github.com/aws/language-servers/commit/ef7d7931954f5083e4a5c358e67c6dc652fa1a40))
+* emit acceptedLineCount metric and AgenticCodeAccepted interaction type ([#2167](https://github.com/aws/language-servers/issues/2167)) ([c53f672](https://github.com/aws/language-servers/commit/c53f672b6173ebda530917ccb4e0c2f26f5c8f79))
+* emit errorMessage in addMessage ([#2197](https://github.com/aws/language-servers/issues/2197)) ([58f2064](https://github.com/aws/language-servers/commit/58f20649d345f159080006120e23cde559826df1))
+* fix calculation for num-lines contributed by the LLM ([#2191](https://github.com/aws/language-servers/issues/2191)) ([fd71e6c](https://github.com/aws/language-servers/commit/fd71e6cf3fc843242936564061061418edf83f56))
+* should send classifier score after taking sigmoid ([#2188](https://github.com/aws/language-servers/issues/2188)) ([f4e2e6e](https://github.com/aws/language-servers/commit/f4e2e6e885e665834a5d7b7cbb5f4ba4ff9bbb65))
+
+
+### Performance Improvements
+
+* only process edit requests 1 at a time ([#2187](https://github.com/aws/language-servers/issues/2187)) ([b497540](https://github.com/aws/language-servers/commit/b4975409a3ed518550290b72ac310895a293be4b))
+
+
+### Reverts
+
+* PR 2172 dedupe openTabs supplemental contexts ([#2194](https://github.com/aws/language-servers/issues/2194)) ([94723d4](https://github.com/aws/language-servers/commit/94723d46073a1ea8211e7ae8f9dfce3fcb809604))
+
+## [0.0.76](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.75...lsp-codewhisperer/v0.0.76) (2025-08-27)
+
+
+### Features
+
+* add basic OAuth client for remote MCP ([#2136](https://github.com/aws/language-servers/issues/2136)) ([2fb896e](https://github.com/aws/language-servers/commit/2fb896e094de0bc5a1b4881067e7dcceb3826015))
+* **amazonq:** emit metric for each issue ([#2179](https://github.com/aws/language-servers/issues/2179)) ([5a3f481](https://github.com/aws/language-servers/commit/5a3f481ebe8c6033e3833abcd81799d26c2aa03e))
+* Auto fetch models from listAvailableModels API ([#2171](https://github.com/aws/language-servers/issues/2171)) ([8600c52](https://github.com/aws/language-servers/commit/8600c524877abb459e9338399352446c0dcff6f0))
+* disable pkce flow during plugin load ([#2153](https://github.com/aws/language-servers/issues/2153)) ([71b3595](https://github.com/aws/language-servers/commit/71b35952333e7581921644ce40fabbc1e6d3c02f))
+* update MCP manager and utilities ([#2158](https://github.com/aws/language-servers/issues/2158)) ([b99df82](https://github.com/aws/language-servers/commit/b99df82826d0ba1a1d52df578cb80674c90505b9))
+
+
+### Bug Fixes
+
+* adding streakTracker to track streakLength across Completions and Edits ([#2147](https://github.com/aws/language-servers/issues/2147)) ([a6c64f2](https://github.com/aws/language-servers/commit/a6c64f2995a17697e3d71d30a1f411f5cf0db279))
+* **amazonq:** dedupe openTabs supplemental contexts ([#2172](https://github.com/aws/language-servers/issues/2172)) ([aa87ae2](https://github.com/aws/language-servers/commit/aa87ae2bd95edc1f38bf90f56093c5bf5ff18c53))
+* **amazonq:** fix for mcp servers operations to edit server config only ([#2165](https://github.com/aws/language-servers/issues/2165)) ([d28df09](https://github.com/aws/language-servers/commit/d28df09ae41871430cd53064eac1f3050c95ea84))
+* **amazonq:** fix to add mcp server tool error handling and status for card ([#2176](https://github.com/aws/language-servers/issues/2176)) ([23f5ec3](https://github.com/aws/language-servers/commit/23f5ec343cb4e0de32926204dbcf99e51af829f9))
+* **amazonq:** status message update for mcp tool permission accpetance ([#2178](https://github.com/aws/language-servers/issues/2178)) ([4893344](https://github.com/aws/language-servers/commit/489334466fa084774d6e4737569468d654dc6359))
+* fix pkce windows url path ([#2173](https://github.com/aws/language-servers/issues/2173)) ([d7b184c](https://github.com/aws/language-servers/commit/d7b184cb12979877722fa0293e9aebec91ff2c18))
+* multiple fixes on auth flow edge cases ([#2155](https://github.com/aws/language-servers/issues/2155)) ([472220a](https://github.com/aws/language-servers/commit/472220a745cff4fe91a2cabae4ae059a164ceddd))
+* reduce auto trigger frequency for VSC ([#2168](https://github.com/aws/language-servers/issues/2168)) ([00e11ff](https://github.com/aws/language-servers/commit/00e11ff48eafaa0baec48177fa4aa6d60048af2f))
+
+
+### Reverts
+
+* reduce auto trigger frequency for VSC ([#2168](https://github.com/aws/language-servers/issues/2168))" ([#2177](https://github.com/aws/language-servers/issues/2177)) ([08720c6](https://github.com/aws/language-servers/commit/08720c6c3fa83f9b3b6775d4ae4d848ce145b94b))
+
+## [0.0.75](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.74...lsp-codewhisperer/v0.0.75) (2025-08-21)
+
+
+### Bug Fixes
+
+* **amazonq:** don't let flare send discard for the still valid suggestion in JB ([#2145](https://github.com/aws/language-servers/issues/2145)) ([0767e07](https://github.com/aws/language-servers/commit/0767e074c91682a91d2fe7a6b2a7369c4dea280c))
+
+## [0.0.74](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.73...lsp-codewhisperer/v0.0.74) (2025-08-19)
+
+
+### Features
+
+* **amazonq:** added mcp admin level configuration with GetProfile ([#2000](https://github.com/aws/language-servers/issues/2000)) ([fd6e9a8](https://github.com/aws/language-servers/commit/fd6e9a829c6229c276de5340dffce52b426a864d))
+* **amazonq:** read tool ui revamp ([#2113](https://github.com/aws/language-servers/issues/2113)) ([#2121](https://github.com/aws/language-servers/issues/2121)) ([93cf229](https://github.com/aws/language-servers/commit/93cf229149ba60491f9f5763793db4a9f570b611))
+* remove project type validation from LSP layer ([#2103](https://github.com/aws/language-servers/issues/2103)) ([d397161](https://github.com/aws/language-servers/commit/d397161cc3448c63016e27f5ac2a1917cdaae1cb))
+
+
+### Bug Fixes
+
+* **amazonq:** add server side control for WCS features ([#2128](https://github.com/aws/language-servers/issues/2128)) ([5e4435d](https://github.com/aws/language-servers/commit/5e4435dfaea7bf8c00e6a27b9bb0d40f699d4e01))
+* **amazonq:** fix regression of mcp config in agent config ([#2101](https://github.com/aws/language-servers/issues/2101)) ([e4e8bbb](https://github.com/aws/language-servers/commit/e4e8bbb89e4b597926582bead2b14ffc43f2a7f8))
+* **amazonq:** handle case where multiple rules are provided with the same name ([#2118](https://github.com/aws/language-servers/issues/2118)) ([0e23e2d](https://github.com/aws/language-servers/commit/0e23e2d29b8cad14403d372b9bbb08ca8ffa7ac7))
+* **amazonq:** persist mcp configs in agent json on start-up ([#2112](https://github.com/aws/language-servers/issues/2112)) ([817cfe2](https://github.com/aws/language-servers/commit/817cfe2656cb1deec6111c699c4ba46b4ba53e00))
+* empty userTriggerDecision not being sent for NEP code path ([#2140](https://github.com/aws/language-servers/issues/2140)) ([b8e5268](https://github.com/aws/language-servers/commit/b8e52682ac2b2337e1d0a32759e8beccde889cee))
+* fix for button text and remove profilearn caching ([#2137](https://github.com/aws/language-servers/issues/2137)) ([2a4171a](https://github.com/aws/language-servers/commit/2a4171a74c15c23c23c481060496162bcc9e6284))
+* fix to add disk caching for mcp admin state ([#2139](https://github.com/aws/language-servers/issues/2139)) ([f947e1a](https://github.com/aws/language-servers/commit/f947e1a9da4431d6089b22825f992010c30a470b))
+* fix to turn on and off MCP servers incase of error based on last state ([#2143](https://github.com/aws/language-servers/issues/2143)) ([04588df](https://github.com/aws/language-servers/commit/04588dfc33f0d85dbd488814a474b5e354398df0))
+* proper path handling for additional context ([#2129](https://github.com/aws/language-servers/issues/2129)) ([971eaa5](https://github.com/aws/language-servers/commit/971eaa505d948e9d2090c85f9b965f554ea7f2c8))
+* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289))
+
+
+### Performance Improvements
+
+* remove edit completion retry mechanism on document change ([#2124](https://github.com/aws/language-servers/issues/2124)) ([963b6e9](https://github.com/aws/language-servers/commit/963b6e9b7887da23a85a826c55a6ed95ff36d956))
+
+
+### Dependencies
+
+* The following workspace dependencies were updated
+ * dependencies
+ * @aws/lsp-core bumped from ^0.0.13 to ^0.0.14
+
## [0.0.73](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.72...lsp-codewhisperer/v0.0.73) (2025-08-11)
diff --git a/server/aws-lsp-codewhisperer/package.json b/server/aws-lsp-codewhisperer/package.json
index fdd34884b1..3fb2f48e69 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.73",
+ "version": "0.0.78",
"description": "CodeWhisperer Language Server",
"main": "out/index.js",
"repository": {
@@ -36,8 +36,8 @@
"@aws-sdk/util-arn-parser": "^3.723.0",
"@aws-sdk/util-retry": "^3.374.0",
"@aws/chat-client-ui-types": "^0.1.56",
- "@aws/language-server-runtimes": "^0.2.123",
- "@aws/lsp-core": "^0.0.13",
+ "@aws/language-server-runtimes": "^0.2.128",
+ "@aws/lsp-core": "^0.0.15",
"@modelcontextprotocol/sdk": "^1.15.0",
"@smithy/node-http-handler": "^2.5.0",
"adm-zip": "^0.5.10",
@@ -67,13 +67,15 @@
"vscode-uri": "^3.1.0",
"ws": "^8.18.0",
"xml2js": "^0.6.2",
- "xmlbuilder2": "^3.1.1"
+ "xmlbuilder2": "^3.1.1",
+ "unescape-html": "^1.1.0"
},
"devDependencies": {
"@types/adm-zip": "^0.5.5",
"@types/archiver": "^6.0.2",
"@types/diff": "^7.0.2",
"@types/encoding-japanese": "^2.2.1",
+ "@types/escape-html": "^1.0.4",
"@types/ignore-walk": "^4.0.3",
"@types/local-indexing": "file:./types/types-local-indexing-1.1.0.tgz",
"@types/lokijs": "^1.5.14",
diff --git a/server/aws-lsp-codewhisperer/src/client/sigv4/codewhisperersigv4client.d.ts b/server/aws-lsp-codewhisperer/src/client/sigv4/codewhisperersigv4client.d.ts
index 308ead42ee..6cb67d3ef8 100644
--- a/server/aws-lsp-codewhisperer/src/client/sigv4/codewhisperersigv4client.d.ts
+++ b/server/aws-lsp-codewhisperer/src/client/sigv4/codewhisperersigv4client.d.ts
@@ -3,7 +3,7 @@
* THIS FILE IS AUTOGENERATED BY 'generateServiceClient.ts'.
* DO NOT EDIT BY HAND.
*/
-
+
import {Request} from 'aws-sdk/lib/request';
import {Response} from 'aws-sdk/lib/response';
import {AWSError} from 'aws-sdk/lib/error';
diff --git a/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json b/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json
index a5704fca16..f54c13cbdd 100644
--- a/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json
+++ b/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json
@@ -1890,6 +1890,10 @@
"type": "string",
"enum": ["BLOCK", "LINE"]
},
+ "SuggestionType": {
+ "type": "string",
+ "enum": ["COMPLETIONS", "EDITS"]
+ },
"Completions": {
"type": "list",
"member": {
@@ -3622,6 +3626,10 @@
"shape": "Models",
"documentation": "List of available models
"
},
+ "defaultModel": {
+ "shape": "Model",
+ "documentation": "Default model set by the client
"
+ },
"nextToken": {
"shape": "Base64EncodedPaginationToken",
"documentation": "Token for retrieving the next page of results
"
@@ -3955,6 +3963,10 @@
"shape": "ModelId",
"documentation": "Unique identifier for the model
"
},
+ "modelName": {
+ "shape": "ModelName",
+ "documentation": "User-facing display name
"
+ },
"description": {
"shape": "Description",
"documentation": "Description of the model
"
@@ -3972,6 +3984,13 @@
"min": 1,
"pattern": "[a-zA-Z0-9_:.-]+"
},
+ "ModelName": {
+ "type": "string",
+ "documentation": "Identifier for the model Name
",
+ "max": 1024,
+ "min": 1,
+ "pattern": "[a-zA-Z0-9-_.]+"
+ },
"ModelMetadata": {
"type": "structure",
"members": {
@@ -4803,6 +4822,12 @@
},
"profileArn": {
"shape": "ProfileArn"
+ },
+ "languageModelId": {
+ "shape": "ModelId"
+ },
+ "clientType": {
+ "shape": "Origin"
}
}
},
@@ -6284,6 +6309,12 @@
},
"ideVersion": {
"shape": "String"
+ },
+ "pluginVersion": {
+ "shape": "String"
+ },
+ "lspVersion": {
+ "shape": "String"
}
}
},
@@ -6525,6 +6556,9 @@
},
"streakLength": {
"shape": "UserTriggerDecisionEventStreakLengthInteger"
+ },
+ "suggestionType": {
+ "shape": "SuggestionType"
}
}
},
diff --git a/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts b/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts
index c885612888..4c8018c84e 100644
--- a/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts
+++ b/server/aws-lsp-codewhisperer/src/client/token/codewhispererbearertokenclient.d.ts
@@ -3,6 +3,7 @@
* THIS FILE IS AUTOGENERATED BY 'generateServiceClient.ts'.
* DO NOT EDIT BY HAND.
*/
+
import {Request} from 'aws-sdk/lib/request';
import {Response} from 'aws-sdk/lib/response';
import {AWSError} from 'aws-sdk/lib/error';
@@ -548,6 +549,7 @@ declare namespace CodeWhispererBearerTokenClient {
}
export type CompletionContentString = string;
export type CompletionType = "BLOCK"|"LINE"|string;
+ export type SuggestionType = "COMPLETIONS"|"EDITS"|string;
export type Completions = Completion[];
export interface ConsoleState {
region?: String;
@@ -1132,6 +1134,10 @@ declare namespace CodeWhispererBearerTokenClient {
* List of available models
*/
models: Models;
+ /**
+ * Default model set by the client
+ */
+ defaultModel?: Model;
/**
* Token for retrieving the next page of results
*/
@@ -1240,6 +1246,10 @@ declare namespace CodeWhispererBearerTokenClient {
* Unique identifier for the model
*/
modelId: ModelId;
+ /**
+ * User-facing display name
+ */
+ modelName?: ModelName;
/**
* Description of the model
*/
@@ -1250,6 +1260,7 @@ declare namespace CodeWhispererBearerTokenClient {
modelMetadata?: ModelMetadata;
}
export type ModelId = string;
+ export type ModelName = string;
export interface ModelMetadata {
/**
* Maximum number of input tokens the model can process
@@ -1528,6 +1539,8 @@ declare namespace CodeWhispererBearerTokenClient {
codeScanName?: CodeScanName;
codeDiffMetadata?: CodeDiffMetadata;
profileArn?: ProfileArn;
+ languageModelId?: ModelId;
+ clientType?: Origin;
}
export type StartCodeAnalysisRequestClientTokenString = string;
export interface StartCodeAnalysisResponse {
@@ -2004,6 +2017,8 @@ declare namespace CodeWhispererBearerTokenClient {
product: UserContextProductString;
clientId?: UUID;
ideVersion?: String;
+ pluginVersion?: String;
+ lspVersion?: String;
}
export type UserContextProductString = string;
export interface UserInputMessage {
@@ -2117,6 +2132,7 @@ declare namespace CodeWhispererBearerTokenClient {
addedCharacterCount?: UserTriggerDecisionEventAddedCharacterCountInteger;
deletedCharacterCount?: UserTriggerDecisionEventDeletedCharacterCountInteger;
streakLength?: UserTriggerDecisionEventStreakLengthInteger;
+ suggestionType?: SuggestionType;
}
export type UserTriggerDecisionEventAddedCharacterCountInteger = number;
export type UserTriggerDecisionEventDeletedCharacterCountInteger = number;
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts
index 6c8af7a272..288eb2c20f 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.test.ts
@@ -32,6 +32,7 @@ import {
InlineChatResult,
CancellationTokenSource,
ContextCommand,
+ ChatUpdateParams,
} from '@aws/language-server-runtimes/server-interface'
import { TestFeatures } from '@aws/language-server-runtimes/testing'
import * as assert from 'assert'
@@ -241,7 +242,7 @@ describe('AgenticChatController', () => {
testFeatures.agent = {
runTool: sinon.stub().resolves({}),
getTools: sinon.stub().returns(
- ['mock-tool-name', 'mock-tool-name-1', 'mock-tool-name-2'].map(toolName => ({
+ ['mock-tool-name', 'mock-tool-name-1', 'mock-tool-name-2', 'codeReview'].map(toolName => ({
toolSpecification: { name: toolName, description: 'Mock tool for testing' },
}))
),
@@ -368,17 +369,6 @@ describe('AgenticChatController', () => {
sinon.assert.calledWithExactly(activeTabSpy.set, mockTabId)
})
- it('onTabAdd updates model ID in chat options and session', () => {
- const modelId = 'test-model-id'
- sinon.stub(ChatDatabase.prototype, 'getModelId').returns(modelId)
- chatController.onTabAdd({ tabId: mockTabId })
-
- sinon.assert.calledWithExactly(testFeatures.chat.chatOptionsUpdate, { modelId, tabId: mockTabId })
-
- const session = chatSessionManagementService.getSession(mockTabId).data
- assert.strictEqual(session!.modelId, modelId)
- })
-
it('onTabChange sets active tab id in telemetryController and emits metrics', () => {
chatController.onTabChange({ tabId: mockTabId })
@@ -451,7 +441,7 @@ describe('AgenticChatController', () => {
assert.deepStrictEqual(chatResult, {
additionalMessages: [],
- body: '\n\nHello World!',
+ body: '\nHello World!',
messageId: 'mock-message-id',
buttons: [],
codeReference: [],
@@ -1150,7 +1140,7 @@ describe('AgenticChatController', () => {
sinon.assert.callCount(testFeatures.lsp.sendProgress, mockChatResponseList.length + 1) // response length + 1 loading messages
assert.deepStrictEqual(chatResult, {
additionalMessages: [],
- body: '\n\nHello World!',
+ body: '\nHello World!',
messageId: 'mock-message-id',
codeReference: [],
buttons: [],
@@ -1169,7 +1159,7 @@ describe('AgenticChatController', () => {
sinon.assert.callCount(testFeatures.lsp.sendProgress, mockChatResponseList.length + 1) // response length + 1 loading message
assert.deepStrictEqual(chatResult, {
additionalMessages: [],
- body: '\n\nHello World!',
+ body: '\nHello World!',
messageId: 'mock-message-id',
buttons: [],
codeReference: [],
@@ -3003,153 +2993,251 @@ ${' '.repeat(8)}}
})
describe('onListAvailableModels', () => {
- let tokenServiceManagerStub: sinon.SinonStub
+ let isCachedModelsValidStub: sinon.SinonStub
+ let getCachedModelsStub: sinon.SinonStub
+ let setCachedModelsStub: sinon.SinonStub
+ let getConnectionTypeStub: sinon.SinonStub
+ let getActiveProfileArnStub: sinon.SinonStub
+ let getCodewhispererServiceStub: sinon.SinonStub
+ let listAvailableModelsStub: sinon.SinonStub
beforeEach(() => {
- // Create a session with a model ID
+ // Create a session
chatController.onTabAdd({ tabId: mockTabId })
- const session = chatSessionManagementService.getSession(mockTabId).data!
- session.modelId = 'CLAUDE_3_7_SONNET_20250219_V1_0'
- // Stub the getRegion method
- tokenServiceManagerStub = sinon.stub(AmazonQTokenServiceManager.prototype, 'getRegion')
+ // Stub ChatDatabase methods
+ isCachedModelsValidStub = sinon.stub(ChatDatabase.prototype, 'isCachedModelsValid')
+ getCachedModelsStub = sinon.stub(ChatDatabase.prototype, 'getCachedModels')
+ setCachedModelsStub = sinon.stub(ChatDatabase.prototype, 'setCachedModels')
+
+ // Stub AmazonQTokenServiceManager methods
+ getConnectionTypeStub = sinon.stub(AmazonQTokenServiceManager.prototype, 'getConnectionType')
+ getActiveProfileArnStub = sinon.stub(AmazonQTokenServiceManager.prototype, 'getActiveProfileArn')
+ getCodewhispererServiceStub = sinon.stub(AmazonQTokenServiceManager.prototype, 'getCodewhispererService')
+
+ // Mock listAvailableModels method
+ listAvailableModelsStub = sinon.stub()
+ getCodewhispererServiceStub.returns({
+ listAvailableModels: listAvailableModelsStub,
+ })
})
afterEach(() => {
- tokenServiceManagerStub.restore()
- })
+ isCachedModelsValidStub.restore()
+ getCachedModelsStub.restore()
+ setCachedModelsStub.restore()
+ getConnectionTypeStub.restore()
+ getActiveProfileArnStub.restore()
+ getCodewhispererServiceStub.restore()
+ })
+
+ describe('ListAvailableModels Cache scenarios', () => {
+ it('should return cached models when cache is valid', async () => {
+ // Setup valid cache
+ isCachedModelsValidStub.returns(true)
+ const cachedData = {
+ models: [
+ { id: 'model1', name: 'Model 1' },
+ { id: 'model2', name: 'Model 2' },
+ ],
+ defaultModelId: 'model1',
+ timestamp: Date.now(),
+ }
+ getCachedModelsStub.returns(cachedData)
- it('should return all available models for us-east-1 region', async () => {
- // Set up the region to be us-east-1
- tokenServiceManagerStub.returns('us-east-1')
+ const session = chatSessionManagementService.getSession(mockTabId).data!
+ session.modelId = 'model1'
- // Call the method
- const params = { tabId: mockTabId }
- const result = await chatController.onListAvailableModels(params)
+ const result = await chatController.onListAvailableModels({ tabId: mockTabId })
- // Verify the result
- assert.strictEqual(result.tabId, mockTabId)
- assert.strictEqual(result.models.length, 2)
- assert.strictEqual(result.selectedModelId, 'CLAUDE_SONNET_4_20250514_V1_0')
+ // Verify cached data is used
+ assert.strictEqual(result.tabId, mockTabId)
+ assert.deepStrictEqual(result.models, cachedData.models)
+ assert.strictEqual(result.selectedModelId, 'model1')
- // Check that the models include both Claude versions
- const modelIds = result.models.map(model => model.id)
- assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'))
- assert.ok(modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0'))
- })
+ // Verify API was not called
+ sinon.assert.notCalled(listAvailableModelsStub)
+ sinon.assert.notCalled(setCachedModelsStub)
+ })
- it('should return all available models for eu-central-1 region', async () => {
- // Set up the region to be eu-central-1
- tokenServiceManagerStub.returns('eu-central-1')
+ it('should return cached models when cache is valid but has empty models array', async () => {
+ // Setup cache with empty models
+ isCachedModelsValidStub.returns(true)
+ const cachedData = {
+ models: [],
+ defaultModelId: undefined,
+ timestamp: Date.now(),
+ }
+ getCachedModelsStub.returns(cachedData)
- // Call the method
- const params = { tabId: mockTabId }
- const result = await chatController.onListAvailableModels(params)
+ // Should fall back to API call since models array is empty
+ getConnectionTypeStub.returns('builderId')
+ getActiveProfileArnStub.returns('test-arn')
+ listAvailableModelsStub.resolves({
+ models: {
+ model1: { modelId: 'model1' },
+ model2: { modelId: 'model2' },
+ },
+ defaultModel: { modelId: 'model1' },
+ })
- // Verify the result
- assert.strictEqual(result.tabId, mockTabId)
- assert.strictEqual(result.models.length, 2)
- assert.strictEqual(result.selectedModelId, 'CLAUDE_SONNET_4_20250514_V1_0')
+ await chatController.onListAvailableModels({ tabId: mockTabId })
- // Check that the models include both Claude versions
- const modelIds = result.models.map(model => model.id)
- assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'))
- assert.ok(modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0'))
- })
+ // Verify API was called due to empty cached models
+ sinon.assert.calledOnce(listAvailableModelsStub)
+ sinon.assert.calledOnce(setCachedModelsStub)
+ })
- it('should return all models when region is unknown', async () => {
- // Set up the region to be unknown
- tokenServiceManagerStub.returns('unknown-region')
+ it('should return cached models when cache is valid but cachedData is null', async () => {
+ // Setup cache as valid but returns null
+ isCachedModelsValidStub.returns(true)
+ getCachedModelsStub.returns(null)
- // Call the method
- const params = { tabId: mockTabId }
- const result = await chatController.onListAvailableModels(params)
+ // Should fall back to API call
+ getConnectionTypeStub.returns('builderId')
+ getActiveProfileArnStub.returns('test-arn')
+ listAvailableModelsStub.resolves({
+ models: {
+ model1: { modelId: 'model1' },
+ },
+ defaultModel: { modelId: 'model1' },
+ })
- // Verify the result
- assert.strictEqual(result.tabId, mockTabId)
- assert.strictEqual(result.models.length, 2)
- assert.strictEqual(result.selectedModelId, 'CLAUDE_SONNET_4_20250514_V1_0')
+ await chatController.onListAvailableModels({ tabId: mockTabId })
+
+ // Verify API was called
+ sinon.assert.calledOnce(listAvailableModelsStub)
+ })
})
- it('should return undefined for selectedModelId when no session data exists', async () => {
- // Set up the session to return no session (failure case)
- const getSessionStub = sinon.stub(chatSessionManagementService, 'getSession')
- getSessionStub.returns({
- data: undefined,
- success: false,
- error: 'error',
+ describe('ListAvailableModels API call scenarios', () => {
+ beforeEach(() => {
+ // Setup invalid cache to force API call
+ isCachedModelsValidStub.returns(false)
})
- // Call the method
- const params = { tabId: 'non-existent-tab' }
- const result = await chatController.onListAvailableModels(params)
+ it('should fetch models from API when cache is invalid', async () => {
+ getConnectionTypeStub.returns('builderId')
+ getActiveProfileArnStub.returns('test-profile-arn')
- // Verify the result
- assert.strictEqual(result.tabId, 'non-existent-tab')
- assert.strictEqual(result.models.length, 2)
- assert.strictEqual(result.selectedModelId, undefined)
+ const mockApiResponse = {
+ models: {
+ 'claude-3-sonnet': { modelId: 'claude-3-sonnet' },
+ 'claude-4-sonnet': { modelId: 'claude-4-sonnet' },
+ },
+ defaultModel: { modelId: 'claude-3-sonnet' },
+ }
+ listAvailableModelsStub.resolves(mockApiResponse)
- getSessionStub.restore()
- })
+ const result = await chatController.onListAvailableModels({ tabId: mockTabId })
+
+ // Verify API call was made with correct parameters
+ sinon.assert.calledOnceWithExactly(listAvailableModelsStub, {
+ origin: 'IDE',
+ profileArn: 'test-profile-arn',
+ })
- it('should fallback to latest available model when saved model is not available in current region', async () => {
- // Import the module to stub
- const modelSelection = await import('./constants/modelSelection')
+ // Verify result structure
+ assert.strictEqual(result.tabId, mockTabId)
+ assert.strictEqual(result.models.length, 2)
+ assert.deepStrictEqual(result.models, [
+ { id: 'claude-3-sonnet', name: 'claude-3-sonnet' },
+ { id: 'claude-4-sonnet', name: 'claude-4-sonnet' },
+ ])
- // Create a mock region with only Claude 3.7
- const mockModelOptionsForRegion = {
- ...modelSelection.MODEL_OPTIONS_FOR_REGION,
- 'test-region-limited': [
- {
- id: 'CLAUDE_3_7_SONNET_20250219_V1_0',
- name: 'Claude Sonnet 3.7',
- },
- ],
- }
+ // Verify cache was updated
+ sinon.assert.calledOnceWithExactly(setCachedModelsStub, result.models, 'claude-3-sonnet')
+ })
+
+ it('should fall back to hardcoded models when API call fails', async () => {
+ getConnectionTypeStub.returns('builderId')
+ listAvailableModelsStub.rejects(new Error('API Error'))
- // Stub the MODEL_OPTIONS_FOR_REGION
- const modelOptionsStub = sinon
- .stub(modelSelection, 'MODEL_OPTIONS_FOR_REGION')
- .value(mockModelOptionsForRegion)
+ const result = await chatController.onListAvailableModels({ tabId: mockTabId })
- // Set up the region to be the test region (which only has Claude 3.7)
- tokenServiceManagerStub.returns('test-region-limited')
+ // Verify fallback to FALLBACK_MODEL_OPTIONS
+ assert.strictEqual(result.tabId, mockTabId)
+ assert.strictEqual(result.models.length, 2) // FALLBACK_MODEL_OPTIONS length
- // Mock database to return Claude Sonnet 4 (not available in test-region-limited)
- const getModelIdStub = sinon.stub(ChatDatabase.prototype, 'getModelId')
- getModelIdStub.returns('CLAUDE_SONNET_4_20250514_V1_0')
+ // Verify cache was not updated due to error
+ sinon.assert.notCalled(setCachedModelsStub)
+ })
- // Call the method
- const params = { tabId: mockTabId }
- const result = await chatController.onListAvailableModels(params)
+ it('should handle API response with no defaultModel', async () => {
+ getConnectionTypeStub.returns('builderId')
+
+ const mockApiResponse = {
+ models: {
+ model1: { modelId: 'model1' },
+ },
+ defaultModel: undefined, // No default model
+ }
+ listAvailableModelsStub.resolves(mockApiResponse)
- // Verify the result falls back to available model
- assert.strictEqual(result.tabId, mockTabId)
- assert.strictEqual(result.models.length, 1)
- assert.strictEqual(result.selectedModelId, 'CLAUDE_3_7_SONNET_20250219_V1_0')
+ const result = await chatController.onListAvailableModels({ tabId: mockTabId })
- getModelIdStub.restore()
- modelOptionsStub.restore()
+ // Verify cache was updated with undefined defaultModelId
+ sinon.assert.calledOnceWithExactly(setCachedModelsStub, result.models, undefined)
+ })
})
- it('should use saved model when it is available in current region', async () => {
- // Set up the region to be us-east-1 (which has both models)
- tokenServiceManagerStub.returns('us-east-1')
+ describe('Session and model selection scenarios', () => {
+ beforeEach(() => {
+ // Setup cache to avoid API calls in these tests
+ isCachedModelsValidStub.returns(true)
+ getCachedModelsStub.returns({
+ models: [
+ { id: 'model1', name: 'Model 1' },
+ { id: 'model2', name: 'Model 2' },
+ ],
+ defaultModelId: 'model1',
+ timestamp: Date.now(),
+ })
+ })
+
+ it('should return default model when session fails to load', async () => {
+ const getSessionStub = sinon.stub(chatSessionManagementService, 'getSession')
+ getSessionStub.returns({
+ data: undefined,
+ success: false,
+ error: 'Session not found',
+ })
+
+ const result = await chatController.onListAvailableModels({ tabId: 'invalid-tab' })
+
+ assert.strictEqual(result.tabId, 'invalid-tab')
+ assert.strictEqual(result.selectedModelId, 'model1')
- // Mock database to return Claude 3.7 (available in us-east-1)
- const getModelIdStub = sinon.stub(ChatDatabase.prototype, 'getModelId')
- getModelIdStub.returns('CLAUDE_3_7_SONNET_20250219_V1_0')
+ getSessionStub.restore()
+ })
+
+ it('should use defaultModelId from cache when session has no modelId', async () => {
+ const session = chatSessionManagementService.getSession(mockTabId).data!
+ session.modelId = undefined
+
+ const result = await chatController.onListAvailableModels({ tabId: mockTabId })
+
+ assert.strictEqual(result.selectedModelId, 'model1') // defaultModelId from cache
+ // Verify session modelId is updated
+ assert.strictEqual(session.modelId, 'model1')
+ })
- // Call the method
- const params = { tabId: mockTabId }
- const result = await chatController.onListAvailableModels(params)
+ it('should fall back to default model when session has no modelId and no defaultModelId in cache', async () => {
+ getCachedModelsStub.returns({
+ models: [{ id: 'model1', name: 'Model 1' }],
+ defaultModelId: undefined, // No default model
+ timestamp: Date.now(),
+ })
- // Verify the result uses the saved model
- assert.strictEqual(result.tabId, mockTabId)
- assert.strictEqual(result.models.length, 2)
- assert.strictEqual(result.selectedModelId, 'CLAUDE_3_7_SONNET_20250219_V1_0')
+ const session = chatSessionManagementService.getSession(mockTabId).data!
+ session.modelId = undefined
- getModelIdStub.restore()
+ const result = await chatController.onListAvailableModels({ tabId: mockTabId })
+
+ assert.strictEqual(result.selectedModelId, 'claude-sonnet-4') // FALLBACK_MODEL_RECORD[DEFAULT_MODEL_ID].label
+ // Verify session modelId is updated
+ assert.strictEqual(session.modelId, 'claude-sonnet-4')
+ })
})
})
@@ -3283,6 +3371,90 @@ ${' '.repeat(8)}}
assert.strictEqual(returnValue, undefined)
})
})
+
+ describe('processToolUses', () => {
+ it('filters rule artifacts from additionalContext for CodeReview tool', async () => {
+ const mockAdditionalContext = [
+ {
+ type: 'file',
+ description: '',
+ name: '',
+ relativePath: '',
+ startLine: 0,
+ endLine: 0,
+ path: '/test/file.js',
+ },
+ {
+ type: 'rule',
+ description: '',
+ name: '',
+ relativePath: '',
+ startLine: 0,
+ endLine: 0,
+ path: '/test/rule1.json',
+ },
+ {
+ type: 'rule',
+ description: '',
+ name: '',
+ relativePath: '',
+ startLine: 0,
+ endLine: 0,
+ path: '/test/rule2.json',
+ },
+ ]
+
+ const toolUse = {
+ toolUseId: 'test-id',
+ name: 'codeReview',
+ input: { fileLevelArtifacts: [{ path: '/test/file.js' }] },
+ stop: true,
+ }
+
+ const runToolStub = testFeatures.agent.runTool as sinon.SinonStub
+ runToolStub.resolves({})
+
+ // Create a mock session with toolUseLookup
+ const mockSession = {
+ toolUseLookup: new Map(),
+ pairProgrammingMode: true,
+ } as any
+
+ // Create a minimal mock of AgenticChatResultStream
+ const mockChatResultStream = {
+ removeResultBlockAndUpdateUI: sinon.stub().resolves(),
+ writeResultBlock: sinon.stub().resolves(1),
+ overwriteResultBlock: sinon.stub().resolves(),
+ removeResultBlock: sinon.stub().resolves(),
+ getMessageBlockId: sinon.stub().returns(undefined),
+ hasMessage: sinon.stub().returns(false),
+ updateOngoingProgressResult: sinon.stub().resolves(),
+ getResult: sinon.stub().returns({ messageId: 'test', body: '' }),
+ setMessageIdToUpdateForTool: sinon.stub(),
+ getMessageIdToUpdateForTool: sinon.stub().returns(undefined),
+ addMessageOperation: sinon.stub(),
+ getMessageOperation: sinon.stub().returns(undefined),
+ }
+
+ // Call processToolUses directly
+ await chatController.processToolUses(
+ [toolUse],
+ mockChatResultStream as any,
+ mockSession,
+ 'tabId',
+ mockCancellationToken,
+ mockAdditionalContext
+ )
+
+ // Verify runTool was called with ruleArtifacts
+ sinon.assert.calledOnce(runToolStub)
+ const toolInput = runToolStub.firstCall.args[1]
+ assert.ok(toolInput.ruleArtifacts)
+ assert.strictEqual(toolInput.ruleArtifacts.length, 2)
+ assert.strictEqual(toolInput.ruleArtifacts[0].path, '/test/rule1.json')
+ assert.strictEqual(toolInput.ruleArtifacts[1].path, '/test/rule2.json')
+ })
+ })
})
// The body may include text-based progress updates from tool invocations.
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts
index 72217fae07..979d2eb857 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts
@@ -24,7 +24,6 @@ import {
GREP_SEARCH,
FILE_SEARCH,
EXECUTE_BASH,
- CODE_REVIEW,
BUTTON_RUN_SHELL_COMMAND,
BUTTON_REJECT_SHELL_COMMAND,
BUTTON_REJECT_MCP_TOOL,
@@ -37,6 +36,7 @@ import {
SUFFIX_PERMISSION,
SUFFIX_UNDOALL,
SUFFIX_EXPLANATION,
+ MAX_DIAGNOSTIC_LOOP_TIMES,
} from './constants/toolConstants'
import {
SendMessageCommandInput,
@@ -67,8 +67,8 @@ import {
ListAvailableModelsResult,
OpenFileDialogParams,
OpenFileDialogResult,
-} from '@aws/language-server-runtimes/protocol'
-import {
+ CheckDiagnosticsParams,
+ CheckDiagnosticsResult,
ApplyWorkspaceEditParams,
ErrorCodes,
FeedbackParams,
@@ -83,6 +83,7 @@ import {
TabBarActionParams,
CreatePromptParams,
FileClickParams,
+ Model,
} from '@aws/language-server-runtimes/protocol'
import {
CancellationToken,
@@ -168,8 +169,9 @@ import { FsWrite, FsWriteParams } from './tools/fsWrite'
import { ExecuteBash, ExecuteBashParams } from './tools/executeBash'
import { ExplanatoryParams, InvokeOutput, ToolApprovalException } from './tools/toolShared'
import { validatePathBasic, validatePathExists, validatePaths as validatePathsSync } from './utils/pathValidation'
+import { calculateModifiedLines } from './utils/fileModificationMetrics'
import { GrepSearch, SanitizedRipgrepOutput } from './tools/grepSearch'
-import { FileSearch, FileSearchParams } from './tools/fileSearch'
+import { FileSearch, FileSearchParams, isFileSearchParams } from './tools/fileSearch'
import { FsReplace, FsReplaceParams } from './tools/fsReplace'
import { loggingUtils, timeoutUtils } from '@aws/lsp-core'
import { diffLines } from 'diff'
@@ -180,7 +182,6 @@ import {
OUTPUT_LIMIT_EXCEEDS_PARTIAL_MSG,
RESPONSE_TIMEOUT_MS,
RESPONSE_TIMEOUT_PARTIAL_MSG,
- DEFAULT_MODEL_ID,
COMPACTION_BODY,
COMPACTION_HEADER_BODY,
DEFAULT_MACOS_RUN_SHORTCUT,
@@ -222,15 +223,17 @@ import {
Message as DbMessage,
messageToStreamingMessage,
} from './tools/chatDb/util'
-import { MODEL_OPTIONS, MODEL_OPTIONS_FOR_REGION } from './constants/modelSelection'
+import { FALLBACK_MODEL_OPTIONS, FALLBACK_MODEL_RECORD, BEDROCK_MODEL_TO_MODEL_ID } from './constants/modelSelection'
import { DEFAULT_IMAGE_VERIFICATION_OPTIONS, verifyServerImage } from '../../shared/imageVerification'
import { sanitize } from '@aws/lsp-core/out/util/path'
-import { getLatestAvailableModel } from './utils/agenticChatControllerHelper'
import { ActiveUserTracker } from '../../shared/activeUserTracker'
import { UserContext } from '../../client/token/codewhispererbearertokenclient'
import { CodeWhispererServiceToken } from '../../shared/codeWhispererService'
import { DisplayFindings } from './tools/qCodeAnalysis/displayFindings'
+import { DiagnosticManager, DiagnosticError } from './diagnosticManager'
+import { IDE } from '../../shared/constants'
import { IdleWorkspaceManager } from '../workspaceContext/IdleWorkspaceManager'
+import escapeHTML = require('escape-html')
type ChatHandlers = Omit<
LspHandlers,
@@ -275,6 +278,8 @@ export class AgenticChatController implements ChatHandlers {
#paidTierMode: PaidTierMode | undefined
#origin: Origin
#activeUserTracker: ActiveUserTracker
+ #modifiedFile = new Set()
+ #diagnosticManager: DiagnosticManager
// latency metrics
#llmRequestStartTime: number = 0
@@ -352,7 +357,7 @@ export class AgenticChatController implements ChatHandlers {
// @ts-ignore
this.#features.chat.chatOptionsUpdate({ region })
})
- this.#chatHistoryDb = new ChatDatabase(features)
+ this.#chatHistoryDb = ChatDatabase.getInstance(features)
this.#tabBarController = new TabBarController(
features,
this.#chatHistoryDb,
@@ -370,6 +375,7 @@ export class AgenticChatController implements ChatHandlers {
this.#mcpEventHandler = new McpEventHandler(features, telemetryService)
this.#origin = getOriginFromClientInfo(getClientName(this.#features.lsp.getClientInitializeParams()))
this.#activeUserTracker = ActiveUserTracker.getInstance(this.#features)
+ this.#diagnosticManager = new DiagnosticManager(features, chatSessionManagementService)
}
async onExecuteCommand(params: ExecuteCommandParams, _token: CancellationToken): Promise {
@@ -544,6 +550,9 @@ export class AgenticChatController implements ChatHandlers {
await this.onButtonClick({ buttonId: BUTTON_UNDO_CHANGES, messageId, tabId })
}
}
+ async getDiagnostics(params: CheckDiagnosticsParams): Promise {
+ return await this.#diagnosticManager.getDiagnostics(params)
+ }
async onOpenFileDialog(params: OpenFileDialogParams, token: CancellationToken): Promise {
if (params.fileType === 'image') {
@@ -681,25 +690,133 @@ export class AgenticChatController implements ChatHandlers {
return this.#mcpEventHandler.onMcpServerClick(params)
}
+ /**
+ * Fetches available models either from cache or API
+ * If cache is valid (less than 5 minutes old), returns cached models
+ * If cache is invalid or empty, makes an API call and stores results in cache
+ * If the API throws errors (e.g., throttling), falls back to default models
+ */
+ async #fetchModelsWithCache(): Promise<{ models: Model[]; defaultModelId?: string; errorFromAPI: boolean }> {
+ let models: Model[] = []
+ let defaultModelId: string | undefined
+ let errorFromAPI = false
+
+ // Check if cache is valid (less than 5 minutes old)
+ if (this.#chatHistoryDb.isCachedModelsValid()) {
+ const cachedData = this.#chatHistoryDb.getCachedModels()
+ if (cachedData && cachedData.models && cachedData.models.length > 0) {
+ this.#log('Using cached models, last updated at:', new Date(cachedData.timestamp).toISOString())
+ return {
+ models: cachedData.models,
+ defaultModelId: cachedData.defaultModelId,
+ errorFromAPI: false,
+ }
+ }
+ }
+
+ // If cache is invalid or empty, make an API call
+ this.#log('Cache miss or expired, fetching models from API')
+ try {
+ const client = AmazonQTokenServiceManager.getInstance().getCodewhispererService()
+ const responseResult = await client.listAvailableModels({
+ origin: IDE,
+ profileArn: AmazonQTokenServiceManager.getInstance().getConnectionType()
+ ? AmazonQTokenServiceManager.getInstance().getActiveProfileArn()
+ : undefined,
+ })
+
+ // Wait for the response to be completed before proceeding
+ this.#log('Model Response: ', JSON.stringify(responseResult, null, 2))
+ models = Object.values(responseResult.models).map(({ modelId, modelName }) => ({
+ id: modelId,
+ name: modelName ?? modelId,
+ }))
+ defaultModelId = responseResult.defaultModel?.modelId
+
+ // Cache the models with defaultModelId
+ this.#chatHistoryDb.setCachedModels(models, defaultModelId)
+ } catch (err) {
+ // In case of API throttling or other errors, fall back to hardcoded models
+ this.#log('Error fetching models from API, using fallback models:', fmtError(err))
+ errorFromAPI = true
+ models = FALLBACK_MODEL_OPTIONS
+ }
+
+ return {
+ models,
+ defaultModelId,
+ errorFromAPI,
+ }
+ }
+
+ /**
+ * This function handles the model selection process for the chat interface.
+ * It first attempts to retrieve models from cache or API, then determines the appropriate model to select
+ * based on the following priority:
+ * 1. When errors occur or session is invalid: Use the default model as a fallback
+ * 2. When user has previously selected a model: Use that model (or its mapped version if the model ID has changed)
+ * 3. When there's a default model from the API: Use the server-recommended default model
+ * 4. Last resort: Use the newest model defined in modelSelection constants
+ *
+ * This ensures users maintain consistent model selection across sessions while also handling
+ * API failures and model ID migrations gracefully.
+ */
async onListAvailableModels(params: ListAvailableModelsParams): Promise {
- const region = AmazonQTokenServiceManager.getInstance().getRegion()
- const models = region && MODEL_OPTIONS_FOR_REGION[region] ? MODEL_OPTIONS_FOR_REGION[region] : MODEL_OPTIONS
+ // Get models from cache or API
+ const { models, defaultModelId, errorFromAPI } = await this.#fetchModelsWithCache()
+
+ // Get the first fallback model option as default
+ const defaultModelOption = FALLBACK_MODEL_OPTIONS[1]
+ const DEFAULT_MODEL_ID = defaultModelId || defaultModelOption?.id
const sessionResult = this.#chatSessionManagementService.getSession(params.tabId)
const { data: session, success } = sessionResult
- if (!success) {
+
+ // Handle error cases by returning default model
+ if (!success || errorFromAPI) {
return {
tabId: params.tabId,
models: models,
+ selectedModelId: DEFAULT_MODEL_ID,
}
}
- const savedModelId = this.#chatHistoryDb.getModelId()
- const selectedModelId =
- savedModelId && models.some(model => model.id === savedModelId)
- ? savedModelId
- : getLatestAvailableModel(region).id
+ // Determine selected model ID based on priority
+ let selectedModelId: string
+ let modelId = this.#chatHistoryDb.getModelId()
+
+ // Helper function to get model label from FALLBACK_MODEL_RECORD
+ const getModelLabel = (modelKey: string) =>
+ FALLBACK_MODEL_RECORD[modelKey as keyof typeof FALLBACK_MODEL_RECORD]?.label || modelKey
+
+ // Helper function to map enum model ID to API model ID
+ const getMappedModelId = (modelKey: string) =>
+ BEDROCK_MODEL_TO_MODEL_ID[modelKey as keyof typeof BEDROCK_MODEL_TO_MODEL_ID] || modelKey
+
+ // Determine selected model ID based on priority
+ if (modelId) {
+ const mappedModelId = getMappedModelId(modelId)
+
+ // Priority 1: Use mapped modelId if it exists in available models from backend
+ if (models.some(model => model.id === mappedModelId)) {
+ selectedModelId = mappedModelId
+ }
+ // Priority 2: Use mapped version if modelId exists in FALLBACK_MODEL_RECORD and no backend models available
+ else if (models.length === 0 && modelId in FALLBACK_MODEL_RECORD) {
+ selectedModelId = getModelLabel(modelId)
+ }
+ // Priority 3: Fall back to default or system default
+ else {
+ selectedModelId = defaultModelId || getMappedModelId(DEFAULT_MODEL_ID)
+ }
+ } else {
+ // No user-selected model - use API default or system default
+ selectedModelId = defaultModelId || getMappedModelId(DEFAULT_MODEL_ID)
+ }
+
+ // Store the selected model in the session
session.modelId = selectedModelId
+
return {
tabId: params.tabId,
models: models,
@@ -1163,6 +1280,7 @@ export class AgenticChatController implements ChatHandlers {
let iterationCount = 0
let shouldDisplayMessage = true
let currentRequestCount = 0
+ let diagnosticLoopTimes = 0
const pinnedContext = additionalContext?.filter(item => item.pinned)
metric.recordStart()
@@ -1256,7 +1374,7 @@ export class AgenticChatController implements ChatHandlers {
this.#debug('Skipping adding user message to history - cancelled by user')
} else {
this.#chatHistoryDb.addMessage(tabId, 'cwc', conversationIdentifier, {
- body: currentMessage.userInputMessage?.content ?? '',
+ body: escapeHTML(currentMessage.userInputMessage?.content ?? ''),
type: 'prompt' as any,
userIntent: currentMessage.userInputMessage?.userIntent,
origin: currentMessage.userInputMessage?.origin,
@@ -1359,7 +1477,6 @@ export class AgenticChatController implements ChatHandlers {
const pendingToolUses = this.#getPendingToolUses(result.data?.toolUses || {})
if (pendingToolUses.length === 0) {
- this.recordChunk('agent_loop_done')
// No more tool uses, we're done
this.#telemetryController.emitAgencticLoop_InvokeLLM(
response.$metadata.requestId!,
@@ -1378,7 +1495,36 @@ export class AgenticChatController implements ChatHandlers {
this.#abTestingAllocation?.experimentName,
this.#abTestingAllocation?.userVariation
)
+ const loopDebugEnabled =
+ this.#features.lsp.getClientInitializeParams()?.initializationOptions?.aws?.awsClientCapabilities?.q
+ ?.LoopDebug
+ // Check for diagnostic errors before setting final result
+ if (
+ loopDebugEnabled &&
+ this.#modifiedFile.size > 0 &&
+ diagnosticLoopTimes < MAX_DIAGNOSTIC_LOOP_TIMES
+ ) {
+ diagnosticLoopTimes += 1
+ const diagnosticErrors = await this.#diagnosticManager.checkDiagnosticErrors(
+ Array.from(this.#modifiedFile)
+ )
+ if (diagnosticErrors.length > 0) {
+ this.#log(`Found ${diagnosticErrors.length} files with diagnostic errors`)
+ // Start a new agent loop to fix the selected errors
+ const fixPrompt = this.#diagnosticManager.generateFixPrompt(diagnosticErrors)
+ currentRequestInput = this.#updateRequestInputWithToolResults(
+ currentRequestInput,
+ [],
+ fixPrompt
+ )
+ shouldDisplayMessage = false
+ continue
+ }
+ }
+
+ this.recordChunk('agent_loop_done')
finalResult = result
+ this.#modifiedFile.clear()
break
}
@@ -1387,7 +1533,14 @@ export class AgenticChatController implements ChatHandlers {
session.setConversationType('AgenticChatWithToolUse')
if (result.success) {
// Process tool uses and update the request input for the next iteration
- toolResults = await this.#processToolUses(pendingToolUses, chatResultStream, session, tabId, token)
+ toolResults = await this.processToolUses(
+ pendingToolUses,
+ chatResultStream,
+ session,
+ tabId,
+ token,
+ additionalContext
+ )
if (toolResults.some(toolResult => this.#shouldSendBackErrorContent(toolResult))) {
content = 'There was an error processing one or more tool uses. Try again, do not apologize.'
shouldDisplayMessage = false
@@ -1662,12 +1815,13 @@ export class AgenticChatController implements ChatHandlers {
/**
* Processes tool uses by running the tools and collecting results
*/
- async #processToolUses(
+ async processToolUses(
toolUses: Array,
chatResultStream: AgenticChatResultStream,
session: ChatSessionService,
tabId: string,
- token?: CancellationToken
+ token?: CancellationToken,
+ additionalContext?: AdditionalContentEntryAddition[]
): Promise {
const results: ToolResult[] = []
@@ -1695,8 +1849,7 @@ export class AgenticChatController implements ChatHandlers {
// remove progress UI
await chatResultStream.removeResultBlockAndUpdateUI(progressPrefix + toolUse.toolUseId)
- // fsRead and listDirectory write to an existing card and could show nothing in the current position
- if (![FS_WRITE, FS_REPLACE, FS_READ, LIST_DIRECTORY].includes(toolUse.name)) {
+ if (![FS_WRITE, FS_REPLACE].includes(toolUse.name)) {
await this.#showUndoAllIfRequired(chatResultStream, session)
}
// fsWrite can take a long time, so we render fsWrite Explanatory upon partial streaming responses.
@@ -1858,6 +2011,7 @@ export class AgenticChatController implements ChatHandlers {
if (toolUse.name === FS_WRITE || toolUse.name === FS_REPLACE) {
const input = toolUse.input as unknown as FsWriteParams | FsReplaceParams
+ this.#modifiedFile.add(input.path)
const document = await this.#triggerContext.getTextDocumentFromPath(input.path, true, true)
session.toolUseLookup.set(toolUse.toolUseId, {
@@ -1869,13 +2023,13 @@ export class AgenticChatController implements ChatHandlers {
if (toolUse.name === CodeReview.toolName) {
try {
let initialInput = JSON.parse(JSON.stringify(toolUse.input))
- let ruleArtifacts = await this.#additionalContextProvider.collectWorkspaceRules(tabId)
- if (ruleArtifacts !== undefined || ruleArtifacts !== null) {
- this.#features.logging.info(`RuleArtifacts: ${JSON.stringify(ruleArtifacts)}`)
- let pathsToRulesMap = ruleArtifacts.map(ruleArtifact => ({ path: ruleArtifact.id }))
- this.#features.logging.info(`PathsToRules: ${JSON.stringify(pathsToRulesMap)}`)
- initialInput['ruleArtifacts'] = pathsToRulesMap
+
+ if (additionalContext !== undefined) {
+ initialInput['ruleArtifacts'] = additionalContext
+ .filter(c => c.type === 'rule')
+ .map(c => ({ path: c.path }))
}
+ initialInput['modelId'] = session.modelId
toolUse.input = initialInput
} catch (e) {
this.#features.logging.warn(`could not parse CodeReview tool input: ${e}`)
@@ -1911,10 +2065,19 @@ export class AgenticChatController implements ChatHandlers {
switch (toolUse.name) {
case FS_READ:
case LIST_DIRECTORY:
+ const readToolResult = await this.#processReadTool(toolUse, chatResultStream)
+ if (readToolResult) {
+ await chatResultStream.writeResultBlock(readToolResult)
+ }
+ break
case FILE_SEARCH:
- const initialListDirResult = this.#processReadOrListOrSearch(toolUse, chatResultStream)
- if (initialListDirResult) {
- await chatResultStream.writeResultBlock(initialListDirResult)
+ if (isFileSearchParams(toolUse.input)) {
+ await this.#processFileSearchTool(
+ toolUse.input,
+ toolUse.toolUseId,
+ result,
+ chatResultStream
+ )
}
break
// no need to write tool result for listDir,fsRead,fileSearch into chat stream
@@ -1955,6 +2118,17 @@ export class AgenticChatController implements ChatHandlers {
this.#abTestingAllocation?.experimentName,
this.#abTestingAllocation?.userVariation
)
+ // Emit acceptedLineCount when write tool is used and code changes are accepted
+ const acceptedLineCount = calculateModifiedLines(toolUse, doc?.getText())
+ await this.#telemetryController.emitInteractWithMessageMetric(
+ tabId,
+ {
+ cwsprChatMessageId: chatResult.messageId ?? toolUse.toolUseId,
+ cwsprChatInteractionType: ChatInteractionType.AgenticCodeAccepted,
+ codewhispererCustomizationArn: this.#customizationArn,
+ },
+ acceptedLineCount
+ )
await chatResultStream.writeResultBlock(chatResult)
break
case CodeReview.toolName:
@@ -2071,6 +2245,50 @@ export class AgenticChatController implements ChatHandlers {
)
}
+ // Handle MCP tool failures
+ const originalNames = McpManager.instance.getOriginalToolNames(toolUse.name)
+ if (originalNames && toolUse.toolUseId) {
+ const { toolName } = originalNames
+ const cachedToolUse = session.toolUseLookup.get(toolUse.toolUseId)
+ const cachedButtonBlockId = (cachedToolUse as any)?.cachedButtonBlockId
+ const customerFacingError = getCustomerFacingErrorMessage(err)
+
+ const errorResult = {
+ type: 'tool',
+ messageId: toolUse.toolUseId,
+ summary: {
+ content: {
+ header: {
+ icon: 'tools',
+ body: `${toolName}`,
+ status: {
+ status: 'error',
+ icon: 'cancel-circle',
+ text: 'Error',
+ description: customerFacingError,
+ },
+ },
+ },
+ collapsedContent: [
+ {
+ header: { body: 'Parameters' },
+ body: `\`\`\`json\n${JSON.stringify(toolUse.input, null, 2)}\n\`\`\``,
+ },
+ {
+ header: { body: 'Error' },
+ body: customerFacingError,
+ },
+ ],
+ },
+ } as ChatResult
+
+ if (cachedButtonBlockId !== undefined) {
+ await chatResultStream.overwriteResultBlock(errorResult, cachedButtonBlockId)
+ } else {
+ await chatResultStream.writeResultBlock(errorResult)
+ }
+ }
+
// display fs write failure status in the UX of that file card
if ((toolUse.name === FS_WRITE || toolUse.name === FS_REPLACE) && toolUse.toolUseId) {
const existingCard = chatResultStream.getMessageBlockId(toolUse.toolUseId)
@@ -2315,7 +2533,6 @@ export class AgenticChatController implements ChatHandlers {
}
const toolMsgId = toolUse.toolUseId!
- const chatMsgId = chatResultStream.getResult().messageId
let headerEmitted = false
const initialHeader: ChatMessage['header'] = {
@@ -2353,13 +2570,6 @@ export class AgenticChatController implements ChatHandlers {
header: completedHeader,
})
- await chatResultStream.writeResultBlock({
- type: 'answer',
- messageId: chatMsgId,
- body: '',
- header: undefined,
- })
-
this.#stoppedToolUses.add(toolMsgId)
},
})
@@ -2450,7 +2660,7 @@ export class AgenticChatController implements ChatHandlers {
status: {
status: isAccept ? 'success' : 'error',
icon: isAccept ? 'ok' : 'cancel',
- text: isAccept ? 'Completed' : 'Rejected',
+ text: isAccept ? 'Accepted' : 'Rejected',
},
fileList: undefined,
},
@@ -2537,6 +2747,7 @@ export class AgenticChatController implements ChatHandlers {
session.setDeferredToolExecution(messageId, deferred.resolve, deferred.reject)
this.#log(`Prompting for compaction approval for messageId: ${messageId}`)
await deferred.promise
+ session.removeDeferredToolExecution(messageId)
// Note: we want to overwrite the button block because it already exists in the stream.
await resultStream.overwriteResultBlock(this.#getUpdateCompactConfirmResult(messageId), promptBlockId)
}
@@ -2877,70 +3088,135 @@ export class AgenticChatController implements ChatHandlers {
}
}
- #processReadOrListOrSearch(toolUse: ToolUse, chatResultStream: AgenticChatResultStream): ChatMessage | undefined {
- let messageIdToUpdate = toolUse.toolUseId!
- const currentId = chatResultStream.getMessageIdToUpdateForTool(toolUse.name!)
+ async #processFileSearchTool(
+ toolInput: FileSearchParams,
+ toolUseId: string,
+ result: InvokeOutput,
+ chatResultStream: AgenticChatResultStream
+ ): Promise {
+ if (typeof result.output.content !== 'string') return
- if (currentId) {
- messageIdToUpdate = currentId
- } else {
- chatResultStream.setMessageIdToUpdateForTool(toolUse.name!, messageIdToUpdate)
+ const { queryName, path: inputPath } = toolInput
+ const resultCount = result.output.content
+ .split('\n')
+ .filter(line => line.trim().startsWith('[F]') || line.trim().startsWith('[D]')).length
+
+ const chatMessage: ChatMessage = {
+ type: 'tool',
+ messageId: toolUseId,
+ header: {
+ body: `Searched for "${queryName}" in `,
+ icon: 'search',
+ status: {
+ text: `${resultCount} result${resultCount !== 1 ? 's' : ''} found`,
+ },
+ fileList: {
+ filePaths: [inputPath],
+ details: {
+ [inputPath]: {
+ description: inputPath,
+ visibleName: path.basename(inputPath),
+ clickable: false,
+ },
+ },
+ },
+ },
}
- let currentPaths = []
+ await chatResultStream.writeResultBlock(chatMessage)
+ }
+
+ async #processReadTool(
+ toolUse: ToolUse,
+ chatResultStream: AgenticChatResultStream
+ ): Promise {
+ let currentPaths: string[] = []
if (toolUse.name === FS_READ) {
- currentPaths = (toolUse.input as unknown as FsReadParams)?.paths
+ currentPaths = (toolUse.input as unknown as FsReadParams)?.paths || []
+ } else if (toolUse.name === LIST_DIRECTORY) {
+ const singlePath = (toolUse.input as unknown as ListDirectoryParams)?.path
+ if (singlePath) {
+ currentPaths = [singlePath]
+ }
+ } else if (toolUse.name === FILE_SEARCH) {
+ const queryName = (toolUse.input as unknown as FileSearchParams)?.queryName
+ if (queryName) {
+ currentPaths = [queryName]
+ }
} else {
- currentPaths.push((toolUse.input as unknown as ListDirectoryParams | FileSearchParams)?.path)
+ return
}
- if (!currentPaths) return
+ if (currentPaths.length === 0) return
- for (const currentPath of currentPaths) {
- const existingPaths = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths || []
- // Check if path already exists in the list
- const isPathAlreadyProcessed = existingPaths.some(path => path.relativeFilePath === currentPath)
- if (!isPathAlreadyProcessed) {
- const currentFileDetail = {
- relativeFilePath: currentPath,
- lineRanges: [{ first: -1, second: -1 }],
- }
- chatResultStream.addMessageOperation(messageIdToUpdate, toolUse.name!, [
- ...existingPaths,
- currentFileDetail,
- ])
+ // Check if the last message is the same tool type
+ const lastMessage = chatResultStream.getLastMessage()
+ const isSameToolType =
+ lastMessage?.type === 'tool' && lastMessage.header?.icon === this.#toolToIcon(toolUse.name)
+
+ let allPaths = currentPaths
+
+ if (isSameToolType && lastMessage.messageId) {
+ // Combine with existing paths and overwrite the last message
+ const existingPaths = lastMessage.header?.fileList?.filePaths || []
+ allPaths = [...existingPaths, ...currentPaths]
+
+ const blockId = chatResultStream.getMessageBlockId(lastMessage.messageId)
+ if (blockId !== undefined) {
+ // Create the updated message with combined paths
+ const updatedMessage = this.#createFileListToolMessage(toolUse, allPaths, lastMessage.messageId)
+ // Overwrite the existing block
+ await chatResultStream.overwriteResultBlock(updatedMessage, blockId)
+ return undefined // Don't return a message since we already wrote it
}
}
+
+ // Create new message with current paths
+ return this.#createFileListToolMessage(toolUse, allPaths, toolUse.toolUseId!)
+ }
+
+ #createFileListToolMessage(toolUse: ToolUse, filePaths: string[], messageId: string): ChatMessage {
+ const itemCount = filePaths.length
let title: string
- const itemCount = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths.length
- const filePathsPushed = chatResultStream.getMessageOperation(messageIdToUpdate)?.filePaths ?? []
- if (!itemCount) {
+ if (itemCount === 0) {
title = 'Gathering context'
} else {
title =
toolUse.name === FS_READ
? `${itemCount} file${itemCount > 1 ? 's' : ''} read`
- : toolUse.name === FILE_SEARCH
- ? `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} searched`
- : `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed`
+ : toolUse.name === LIST_DIRECTORY
+ ? `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed`
+ : ''
}
const details: Record = {}
- for (const item of filePathsPushed) {
- details[item.relativeFilePath] = {
- lineRanges: item.lineRanges,
- description: item.relativeFilePath,
+ for (const filePath of filePaths) {
+ details[filePath] = {
+ description: filePath,
+ visibleName: path.basename(filePath),
+ clickable: toolUse.name === FS_READ,
}
}
-
- const fileList: FileList = {
- rootFolderTitle: title,
- filePaths: filePathsPushed.map(item => item.relativeFilePath),
- details,
- }
return {
type: 'tool',
- fileList,
- messageId: messageIdToUpdate,
- body: '',
+ header: {
+ body: title,
+ icon: this.#toolToIcon(toolUse.name),
+ fileList: {
+ filePaths,
+ details,
+ },
+ },
+ messageId,
+ }
+ }
+
+ #toolToIcon(toolName: string | undefined): string | undefined {
+ switch (toolName) {
+ case FS_READ:
+ return 'eye'
+ case LIST_DIRECTORY:
+ return 'check-list'
+ default:
+ return undefined
}
}
@@ -2956,14 +3232,7 @@ export class AgenticChatController implements ChatHandlers {
return undefined
}
- let messageIdToUpdate = toolUse.toolUseId!
- const currentId = chatResultStream.getMessageIdToUpdateForTool(toolUse.name!)
-
- if (currentId) {
- messageIdToUpdate = currentId
- } else {
- chatResultStream.setMessageIdToUpdateForTool(toolUse.name!, messageIdToUpdate)
- }
+ const messageIdToUpdate = toolUse.toolUseId!
// Extract search results from the tool output
const output = result.output.content as SanitizedRipgrepOutput
@@ -3143,7 +3412,7 @@ export class AgenticChatController implements ChatHandlers {
metric: Metric,
agenticCodingMode: boolean
): Promise> {
- const errorMessage = getErrorMsg(err)
+ const errorMessage = getErrorMsg(err) ?? GENERIC_ERROR_MS
const requestID = getRequestID(err) ?? ''
metric.setDimension('cwsprChatResponseCode', getHttpStatusCode(err) ?? 0)
metric.setDimension('languageServerVersion', this.#features.runtime.serverInfo.version)
@@ -3153,7 +3422,7 @@ export class AgenticChatController implements ChatHandlers {
metric.metric.requestIds = [requestID]
metric.metric.cwsprChatMessageId = errorMessageId
metric.metric.cwsprChatConversationId = conversationId
- await this.#telemetryController.emitAddMessageMetric(tabId, metric.metric, 'Failed')
+ await this.#telemetryController.emitAddMessageMetric(tabId, metric.metric, 'Failed', errorMessage)
if (isUsageLimitError(err)) {
if (this.#paidTierMode !== 'paidtier') {
@@ -3198,7 +3467,7 @@ export class AgenticChatController implements ChatHandlers {
tabId,
metric.metric,
requestID,
- errorMessage ?? GENERIC_ERROR_MS,
+ errorMessage,
agenticCodingMode
)
}
@@ -3515,25 +3784,6 @@ export class AgenticChatController implements ChatHandlers {
onSourceLinkClick() {}
- /**
- * @deprecated use aws/chat/listAvailableModels server request instead
- */
- #legacySetModelId(tabId: string, session: ChatSessionService) {
- // Since model selection is mandatory, the only time modelId is not set is when the chat history is empty.
- // In that case, we use the default modelId.
- let modelId = this.#chatHistoryDb.getModelId() ?? DEFAULT_MODEL_ID
-
- const region = AmazonQTokenServiceManager.getInstance().getRegion()
- if (region === 'eu-central-1') {
- // Only 3.7 Sonnet is available in eu-central-1 for now
- modelId = 'CLAUDE_3_7_SONNET_20250219_V1_0'
- // @ts-ignore
- this.#features.chat.chatOptionsUpdate({ region })
- }
- this.#features.chat.chatOptionsUpdate({ modelId: modelId, tabId: tabId })
- session.modelId = modelId
- }
-
onTabAdd(params: TabAddParams) {
this.#telemetryController.activeTabId = params.tabId
@@ -3546,11 +3796,14 @@ export class AgenticChatController implements ChatHandlers {
if (!success) {
return new ResponseError(ErrorCodes.InternalError, sessionResult.error)
}
- this.#legacySetModelId(params.tabId, session)
// Get the saved pair programming mode from the database or default to true if not found
const savedPairProgrammingMode = this.#chatHistoryDb.getPairProgrammingMode()
session.pairProgrammingMode = savedPairProgrammingMode !== undefined ? savedPairProgrammingMode : true
+ if (session) {
+ // Set the logging object on the session
+ session.setLogging(this.#features.logging)
+ }
// Update the client with the initial pair programming mode
this.#features.chat.chatOptionsUpdate({
@@ -3558,11 +3811,6 @@ export class AgenticChatController implements ChatHandlers {
// Type assertion to support pairProgrammingMode
...(session.pairProgrammingMode !== undefined ? { pairProgrammingMode: session.pairProgrammingMode } : {}),
} as ChatUpdateParams)
-
- if (success && session) {
- // Set the logging object on the session
- session.setLogging(this.#features.logging)
- }
this.setPaidTierMode(params.tabId)
}
@@ -4418,6 +4666,13 @@ export class AgenticChatController implements ChatHandlers {
await chatResultStream.overwriteResultBlock(toolResultCard, cachedButtonBlockId)
} else {
// Fallback to creating a new card
+ if (toolResultCard.summary?.content?.header) {
+ toolResultCard.summary.content.header.status = {
+ status: 'success',
+ icon: 'ok',
+ text: 'Completed',
+ }
+ }
this.#log(`Warning: No blockId found for tool use ${toolUse.toolUseId}, creating new card`)
await chatResultStream.writeResultBlock(toolResultCard)
}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatResultStream.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatResultStream.ts
index 70b3452361..195e1e880b 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatResultStream.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatResultStream.ts
@@ -1,4 +1,4 @@
-import { ChatResult, FileDetails, ChatMessage } from '@aws/language-server-runtimes/protocol'
+import { ChatResult, ChatMessage } from '@aws/language-server-runtimes/protocol'
import { randomUUID } from 'crypto'
export interface ResultStreamWriter {
@@ -32,33 +32,20 @@ export interface ResultStreamWriter {
close(): Promise
}
+export const progressPrefix = 'progress_'
+
/**
* This class wraps around lsp.sendProgress to provide a more helpful interface for streaming a ChatResult to the client.
* ChatResults are grouped into blocks that can be written directly, or streamed in.
* In the final message, blocks are seperated by resultDelimiter defined below.
*/
-
-interface FileDetailsWithPath extends FileDetails {
- relativeFilePath: string
-}
-
-type OperationType = 'read' | 'write' | 'listDir'
-
-export const progressPrefix = 'progress_'
-
-interface FileOperation {
- type: OperationType
- filePaths: FileDetailsWithPath[]
-}
export class AgenticChatResultStream {
- static readonly resultDelimiter = '\n\n'
+ static readonly resultDelimiter = '\n'
#state = {
chatResultBlocks: [] as ChatMessage[],
isLocked: false,
uuid: randomUUID(),
messageId: undefined as string | undefined,
- messageIdToUpdateForTool: new Map(),
- messageOperations: new Map(),
}
readonly #sendProgress: (newChatResult: ChatResult | string) => Promise
@@ -70,33 +57,6 @@ export class AgenticChatResultStream {
return this.#joinResults(this.#state.chatResultBlocks, only)
}
- setMessageIdToUpdateForTool(toolName: string, messageId: string) {
- this.#state.messageIdToUpdateForTool.set(toolName as OperationType, messageId)
- }
-
- getMessageIdToUpdateForTool(toolName: string): string | undefined {
- return this.#state.messageIdToUpdateForTool.get(toolName as OperationType)
- }
-
- /**
- * Adds a file operation for a specific message
- * @param messageId The ID of the message
- * @param type The type of operation ('fsRead' or 'listDirectory' or 'fsWrite')
- * @param filePaths Array of FileDetailsWithPath involved in the operation
- */
- addMessageOperation(messageId: string, type: string, filePaths: FileDetailsWithPath[]) {
- this.#state.messageOperations.set(messageId, { type: type as OperationType, filePaths })
- }
-
- /**
- * Gets the file operation details for a specific message
- * @param messageId The ID of the message
- * @returns The file operation details or undefined if not found
- */
- getMessageOperation(messageId: string): FileOperation | undefined {
- return this.#state.messageOperations.get(messageId)
- }
-
#joinResults(chatResults: ChatMessage[], only?: string): ChatResult {
const result: ChatResult = {
body: '',
@@ -111,9 +71,9 @@ export class AgenticChatResultStream {
return {
...acc,
buttons: [...(acc.buttons ?? []), ...(c.buttons ?? [])],
- body: acc.body + AgenticChatResultStream.resultDelimiter + c.body,
- ...(c.contextList && { contextList: c.contextList }),
- header: Object.prototype.hasOwnProperty.call(c, 'header') ? c.header : acc.header,
+ body: acc.body + (c.body ? AgenticChatResultStream.resultDelimiter + c.body : ''),
+ ...(c.contextList && c.type !== 'tool' && { contextList: c.contextList }),
+ header: c.header !== undefined ? c.header : acc.header,
codeReference: [...(acc.codeReference ?? []), ...(c.codeReference ?? [])],
}
} else if (acc.additionalMessages!.some(am => am.messageId === c.messageId)) {
@@ -127,9 +87,10 @@ export class AgenticChatResultStream {
: am.buttons,
body:
am.messageId === c.messageId
- ? am.body + AgenticChatResultStream.resultDelimiter + c.body
+ ? am.body + (c.body ? AgenticChatResultStream.resultDelimiter + c.body : '')
: am.body,
...(am.messageId === c.messageId &&
+ c.type !== 'tool' &&
(c.contextList || acc.contextList) && {
contextList: {
filePaths: [
@@ -161,7 +122,7 @@ export class AgenticChatResultStream {
},
},
}),
- header: Object.prototype.hasOwnProperty.call(c, 'header') ? c.header : am.header,
+ ...(am.messageId === c.messageId && c.header !== undefined && { header: c.header }),
})),
}
} else {
@@ -246,6 +207,10 @@ export class AgenticChatResultStream {
return undefined
}
+ getLastMessage(): ChatMessage {
+ return this.#state.chatResultBlocks[this.#state.chatResultBlocks.length - 1]
+ }
+
getResultStreamWriter(): ResultStreamWriter {
// Note: if write calls are not awaited, stream can be out-of-order.
if (this.#state.isLocked) {
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts
index 1729c10c1d..b2ac428cfa 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts
@@ -14,7 +14,6 @@ export const SERVICE_MANAGER_POLL_INTERVAL_MS = 100
// LLM Constants
export const GENERATE_ASSISTANT_RESPONSE_INPUT_LIMIT = 500_000
-export const DEFAULT_MODEL_ID = BedrockModel.CLAUDE_SONNET_4_20250514_V1_0
// Compaction
export const COMPACTION_BODY = (threshold: number) =>
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts
index 3fe300d2fd..f037470bec 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.test.ts
@@ -1,19 +1,19 @@
import * as assert from 'assert'
-import { MODEL_OPTIONS, MODEL_OPTIONS_FOR_REGION } from './modelSelection'
+import { FALLBACK_MODEL_OPTIONS } from './modelSelection'
describe('modelSelection', () => {
describe('modelOptions', () => {
it('should contain the correct model options', () => {
- assert.ok(Array.isArray(MODEL_OPTIONS), 'modelOptions should be an array')
- assert.strictEqual(MODEL_OPTIONS.length, 2, 'modelOptions should have 2 items')
+ assert.ok(Array.isArray(FALLBACK_MODEL_OPTIONS), 'modelOptions should be an array')
+ assert.strictEqual(FALLBACK_MODEL_OPTIONS.length, 2, 'modelOptions should have 2 items')
// Check that the array contains the expected models
- const modelIds = MODEL_OPTIONS.map(model => model.id)
- assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'), 'Should include Claude Sonnet 4')
- assert.ok(modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0'), 'Should include Claude Sonnet 3.7')
+ const modelIds = FALLBACK_MODEL_OPTIONS.map(model => model.id)
+ assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'), 'Should include claude-sonnet-4')
+ assert.ok(modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0'), 'Should include claude-3.7-sonnet')
// Check that each model has the required properties
- MODEL_OPTIONS.forEach(model => {
+ FALLBACK_MODEL_OPTIONS.forEach(model => {
assert.ok('id' in model, 'Model should have id property')
assert.ok('name' in model, 'Model should have name property')
assert.strictEqual(typeof model.id, 'string', 'Model id should be a string')
@@ -21,47 +21,11 @@ describe('modelSelection', () => {
})
// Check specific model names
- const claudeSonnet4 = MODEL_OPTIONS.find(model => model.id === 'CLAUDE_SONNET_4_20250514_V1_0')
- const claudeSonnet37 = MODEL_OPTIONS.find(model => model.id === 'CLAUDE_3_7_SONNET_20250219_V1_0')
+ const claudeSonnet4 = FALLBACK_MODEL_OPTIONS.find(model => model.id === 'CLAUDE_SONNET_4_20250514_V1_0')
+ const claudeSonnet37 = FALLBACK_MODEL_OPTIONS.find(model => model.id === 'CLAUDE_3_7_SONNET_20250219_V1_0')
- assert.strictEqual(claudeSonnet4?.name, 'Claude Sonnet 4', 'Claude Sonnet 4 should have correct name')
- assert.strictEqual(claudeSonnet37?.name, 'Claude Sonnet 3.7', 'Claude Sonnet 3.7 should have correct name')
- })
- })
-
- describe('modelOptionsForRegion', () => {
- it('should provide all models for us-east-1 region', () => {
- const usEast1Models = MODEL_OPTIONS_FOR_REGION['us-east-1']
- assert.deepStrictEqual(usEast1Models, MODEL_OPTIONS, 'us-east-1 should have all models')
- assert.strictEqual(usEast1Models.length, 2, 'us-east-1 should have 2 models')
-
- const modelIds = usEast1Models.map(model => model.id)
- assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'), 'us-east-1 should include Claude Sonnet 4')
- assert.ok(
- modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0'),
- 'us-east-1 should include Claude Sonnet 3.7'
- )
- })
-
- it('should provide all models for eu-central-1 region', () => {
- const euCentral1Models = MODEL_OPTIONS_FOR_REGION['eu-central-1']
- assert.deepStrictEqual(euCentral1Models, MODEL_OPTIONS, 'us-east-1 should have all models')
- assert.strictEqual(euCentral1Models.length, 2, 'us-east-1 should have 2 models')
-
- const modelIds = euCentral1Models.map(model => model.id)
- assert.ok(modelIds.includes('CLAUDE_SONNET_4_20250514_V1_0'), 'eu-central-1 should include Claude Sonnet 4')
- assert.ok(
- modelIds.includes('CLAUDE_3_7_SONNET_20250219_V1_0'),
- 'eu-central-1 should include Claude Sonnet 3.7'
- )
- })
-
- it('should fall back to all models for unknown regions', () => {
- // Test with a region that doesn't exist in the modelOptionsForRegion map
- const unknownRegionModels = MODEL_OPTIONS_FOR_REGION['unknown-region']
-
- // Should be undefined since the region doesn't exist in the map
- assert.strictEqual(unknownRegionModels, undefined, 'Unknown region should return undefined')
+ assert.strictEqual(claudeSonnet4?.name, 'Claude Sonnet 4', 'claude-sonnet-4 should have correct name')
+ assert.strictEqual(claudeSonnet37?.name, 'Claude 3.7 Sonnet', 'claude-3.7-sonnet should have correct name')
})
})
})
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts
index cbee85f562..46c5446c8c 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/modelSelection.ts
@@ -1,5 +1,8 @@
import { ListAvailableModelsResult } from '@aws/language-server-runtimes/protocol'
+/**
+ * @deprecated Do not add new models to the enum.
+ */
export enum BedrockModel {
CLAUDE_SONNET_4_20250514_V1_0 = 'CLAUDE_SONNET_4_20250514_V1_0',
CLAUDE_3_7_SONNET_20250219_V1_0 = 'CLAUDE_3_7_SONNET_20250219_V1_0',
@@ -9,19 +12,19 @@ type ModelDetails = {
label: string
}
-const MODEL_RECORD: Record = {
- [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'Claude Sonnet 3.7' },
+export const FALLBACK_MODEL_RECORD: Record = {
+ [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'Claude 3.7 Sonnet' },
[BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { label: 'Claude Sonnet 4' },
}
-export const MODEL_OPTIONS: ListAvailableModelsResult['models'] = Object.entries(MODEL_RECORD).map(
+export const BEDROCK_MODEL_TO_MODEL_ID: Record = {
+ [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: 'claude-3.7-sonnet',
+ [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: 'claude-sonnet-4',
+}
+
+export const FALLBACK_MODEL_OPTIONS: ListAvailableModelsResult['models'] = Object.entries(FALLBACK_MODEL_RECORD).map(
([value, { label }]) => ({
id: value,
name: label,
})
)
-
-export const MODEL_OPTIONS_FOR_REGION: Record = {
- 'us-east-1': MODEL_OPTIONS,
- 'eu-central-1': MODEL_OPTIONS,
-}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/toolConstants.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/toolConstants.ts
index dff24d7fb7..c0cee11042 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/toolConstants.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/toolConstants.ts
@@ -36,3 +36,6 @@ export const BUTTON_PAIDTIER_UPGRADE_Q = 'paidtier-upgrade-q'
export const SUFFIX_PERMISSION = '_permission'
export const SUFFIX_UNDOALL = '_undoall'
export const SUFFIX_EXPLANATION = '_explanation'
+
+// Max loop times
+export const MAX_DIAGNOSTIC_LOOP_TIMES = 5
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.test.ts
index 6c745f20b3..e6742fee1b 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.test.ts
@@ -174,6 +174,12 @@ describe('AdditionalContextProvider', () => {
workspaceFolder: mockWorkspaceFolder,
}
+ // Mock path.join to simulate Unix behavior
+ sinon.stub(path, 'join').callsFake((...args) => {
+ // Simulate Unix path.join behavior
+ return args.join('/').replace(/\\/g, '/')
+ })
+
const explicitContext = [
{
id: 'explicit-file',
@@ -208,6 +214,9 @@ describe('AdditionalContextProvider', () => {
assert.strictEqual(result.length, 1)
assert.strictEqual(result[0].name, 'Explicit File')
assert.strictEqual(result[0].pinned, false)
+
+ // Restore original path.join
+ ;(path.join as sinon.SinonStub).restore()
})
it('should avoid duplicates between explicit and pinned context', async () => {
@@ -220,6 +229,12 @@ describe('AdditionalContextProvider', () => {
workspaceFolder: mockWorkspaceFolder,
}
+ // Mock path.join to simulate Unix behavior
+ sinon.stub(path, 'join').callsFake((...args) => {
+ // Simulate Unix path.join behavior
+ return args.join('/').replace(/\\/g, '/')
+ })
+
const sharedContext = {
id: 'shared-file',
command: 'Shared File',
@@ -255,6 +270,9 @@ describe('AdditionalContextProvider', () => {
assert.strictEqual(result.length, 1)
assert.strictEqual(result[0].name, 'Shared File')
assert.strictEqual(result[0].pinned, false) // Should be marked as explicit, not pinned
+
+ // Restore original path.join
+ ;(path.join as sinon.SinonStub).restore()
})
it('should handle Active File context correctly', async () => {
@@ -358,6 +376,105 @@ describe('AdditionalContextProvider', () => {
assert.strictEqual(triggerContext.contextInfo?.pinnedContextCount.codeContextCount, 1)
assert.strictEqual(triggerContext.contextInfo?.pinnedContextCount.promptContextCount, 1)
})
+
+ it('should handle Unix path separators correctly', async () => {
+ const mockWorkspaceFolder = { uri: URI.file('/workspace').toString(), name: 'test' }
+ sinon.stub(workspaceUtils, 'getWorkspaceFolderPaths').returns(['/workspace'])
+
+ // Mock path.join to simulate Unix behavior
+ sinon.stub(path, 'join').callsFake((...args) => {
+ // Simulate Unix path.join behavior
+ return args.join('/').replace(/\\/g, '/')
+ })
+
+ const explicitContext = [
+ {
+ id: 'unix-prompt',
+ command: 'Unix Prompt',
+ label: 'file' as any,
+ route: ['/Users/test/.aws/amazonq/prompts', 'hello.md'],
+ },
+ ]
+
+ fsExistsStub.callsFake((path: string) => path.includes('.amazonq/rules'))
+ fsReadDirStub.resolves([])
+
+ // Reset stub - return data for first call (explicit context), empty for second call (pinned context)
+ getContextCommandPromptStub.reset()
+ getContextCommandPromptStub.onFirstCall().resolves([
+ {
+ // promptContextCommands - explicit context
+ name: 'Unix Prompt',
+ content: 'content',
+ filePath: '/Users/test/.aws/amazonq/prompts/hello.md', // Proper Unix path
+ relativePath: 'hello.md',
+ startLine: 1,
+ endLine: 10,
+ },
+ ])
+ getContextCommandPromptStub.onSecondCall().resolves([]) // pinnedContextCommands - empty
+
+ const result = await provider.getAdditionalContext(
+ { workspaceFolder: mockWorkspaceFolder },
+ 'tab1',
+ explicitContext
+ )
+ assert.strictEqual(result.length, 1)
+ assert.strictEqual(result[0].name, 'Unix Prompt')
+
+ // Restore original path.join
+ ;(path.join as sinon.SinonStub).restore()
+ })
+
+ it('should handle Windows path separators correctly', async () => {
+ const mockWorkspaceFolder = { uri: URI.file('/workspace').toString(), name: 'test' }
+ sinon.stub(workspaceUtils, 'getWorkspaceFolderPaths').returns(['/workspace'])
+
+ // Mock path.join to simulate Windows behavior
+ const originalPathJoin = path.join
+ sinon.stub(path, 'join').callsFake((...args) => {
+ // Simulate Windows path.join behavior
+ return args.join('\\').replace(/\//g, '\\')
+ })
+
+ const explicitContext = [
+ {
+ id: 'windows-prompt',
+ command: 'Windows Prompt',
+ label: 'file' as any,
+ route: ['C:\\Users\\test\\.aws\\amazonq\\prompts', 'hello.md'],
+ },
+ ]
+
+ fsExistsStub.callsFake((path: string) => path.includes('.amazonq/rules'))
+ fsReadDirStub.resolves([])
+
+ // Reset stub - return data for first call (explicit context), empty for second call (pinned context)
+ getContextCommandPromptStub.reset()
+ getContextCommandPromptStub.onFirstCall().resolves([
+ {
+ // promptContextCommands - explicit context
+ name: 'Windows Prompt',
+ content: 'content',
+ filePath: 'C:\\Users\\test\\.aws\\amazonq\\prompts\\hello.md', // Proper Windows path
+ relativePath: 'hello.md',
+ startLine: 1,
+ endLine: 10,
+ },
+ ])
+ getContextCommandPromptStub.onSecondCall().resolves([]) // pinnedContextCommands - empty
+
+ const result = await provider.getAdditionalContext(
+ { workspaceFolder: mockWorkspaceFolder },
+ 'tab1',
+ explicitContext
+ )
+ assert.strictEqual(result.length, 1)
+ assert.strictEqual(result[0].name, 'Windows Prompt')
+
+ // Restore original path.join
+ ;(path.join as sinon.SinonStub).restore()
+ })
})
describe('getFileListFromContext', () => {
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.ts
index d71da1a1fe..733d1df28c 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/context/additionalContextProvider.ts
@@ -406,7 +406,7 @@ export class AdditionalContextProvider {
const image = imageMap.get(item.description)
if (image) ordered.push(image)
} else {
- const doc = item.route ? docMap.get(item.route.join('/')) : undefined
+ const doc = item.route ? docMap.get(path.join(...item.route)) : undefined
if (doc) ordered.push(doc)
}
}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/diagnosticManager.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/diagnosticManager.ts
new file mode 100644
index 0000000000..5fccfb07d3
--- /dev/null
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/diagnosticManager.ts
@@ -0,0 +1,149 @@
+import { CheckDiagnosticsParams, CheckDiagnosticsResult, DiagnosticInfo } from '@aws/language-server-runtimes/protocol'
+import { Features } from '../types'
+import { ChatSessionManagementService } from '../chat/chatSessionManagementService'
+
+export interface DiagnosticError {
+ filePath: string
+ diagnostics: DiagnosticInfo[]
+ errorCount: number
+}
+// Maximum number of diagnostic errors to include per file (limits output size)
+const MAX_ERROR_NUMBER = 50
+// Severity level threshold: 1=Error, 2=Warning, 3=Information, 4=Hint (include errors and warnings only)
+const SEVERITY_LEVEL_THRESHOLD = 2
+
+interface DiagnosticDeferred {
+ resolve: (value: DiagnosticError[]) => void
+ reject: (error: Error) => void
+ promise: Promise
+}
+
+export class DiagnosticManager {
+ #features: Features
+ #chatSessionManagementService: ChatSessionManagementService
+ #diagnosticErrorsDeferred: DiagnosticDeferred | undefined
+ #currentDiagnosticErrors: DiagnosticError[] = []
+ #selectedDiagnosticFiles = new Set()
+
+ constructor(features: Features, chatSessionManagementService: ChatSessionManagementService) {
+ this.#features = features
+ this.#chatSessionManagementService = chatSessionManagementService
+ }
+
+ /**
+ * Get diagnostics from the language server
+ */
+ async getDiagnostics(params: CheckDiagnosticsParams): Promise {
+ return await this.#features.lsp.window.checkDiagnostics(params)
+ }
+
+ /**
+ * Check for diagnostic errors in the specified files
+ */
+ async checkDiagnosticErrors(filePaths: string[]): Promise {
+ // First, open files to ensure diagnostics are available
+ await this.#ensureFilesAreAnalyzed(filePaths)
+
+ const diagnosticParams: CheckDiagnosticsParams = { fileDiagnostics: {} }
+ for (const file of filePaths) {
+ diagnosticParams.fileDiagnostics[file] = []
+ }
+
+ try {
+ const diagnosticResult = await this.getDiagnostics(diagnosticParams)
+ const errors: DiagnosticError[] = []
+
+ for (const [filePath, diagnostics] of Object.entries(diagnosticResult.fileDiagnostics)) {
+ if (Array.isArray(diagnostics) && diagnostics.length > 0) {
+ // Filter for errors and warnings only (severity 1 and 2)
+ const errorDiagnostics = diagnostics.filter(
+ diag => diag.severity === undefined || diag.severity <= SEVERITY_LEVEL_THRESHOLD
+ )
+
+ if (errorDiagnostics.length > 0) {
+ // Limit to 10 errors per file
+ const limitedDiagnostics = errorDiagnostics.slice(0, MAX_ERROR_NUMBER)
+ errors.push({
+ filePath,
+ diagnostics: limitedDiagnostics,
+ errorCount: errorDiagnostics.length,
+ })
+ }
+ }
+ }
+
+ return errors
+ } catch (error) {
+ this.#log(`Failed to get diagnostic information: ${error}`)
+ return []
+ }
+ }
+
+ /**
+ * Ensure files are opened/analyzed so diagnostics are available
+ */
+ async #ensureFilesAreAnalyzed(filePaths: string[]): Promise {
+ const openPromises = filePaths.map(async filePath => {
+ try {
+ // Open the file without taking focus to trigger diagnostic analysis
+ await this.#features.lsp.workspace.openWorkspaceFile({
+ filePath: filePath,
+ makeActive: true,
+ })
+ } catch (error) {
+ this.#log(`Failed to open file for diagnostic analysis: ${filePath} - ${error}`)
+ }
+ })
+
+ // Wait for all files to be opened
+ await Promise.all(openPromises)
+
+ // Give language servers a moment to analyze the files
+ await new Promise(resolve => setTimeout(resolve, 1500))
+ }
+ /**
+ * Generate a prompt to fix diagnostic errors
+ */
+ generateFixPrompt(diagnosticErrors: DiagnosticError[]): string {
+ const errorDescriptions = diagnosticErrors
+ .map(error => {
+ const fileName = error.filePath.split('/').pop() || error.filePath
+ const errorList = error.diagnostics
+ .map(diag => `Line ${diag.range.start.line + 1}: ${diag.message}`)
+ .join('\n')
+
+ return `File: ${fileName} (${error.filePath})\n${errorList}`
+ })
+ .join('\n\n')
+
+ return `Please fix all errors below:\n\n${errorDescriptions}\n\n`
+ }
+ /**
+ * Log helper
+ */
+ #log(message: string): void {
+ this.#features.logging.info(`DiagnosticManager: ${message}`)
+ }
+
+ /**
+ * Get current diagnostic errors (for external access)
+ */
+ getCurrentDiagnosticErrors(): DiagnosticError[] {
+ return this.#currentDiagnosticErrors
+ }
+ /**
+ * Check if there's a pending diagnostic decision
+ */
+ hasPendingDiagnosticDecision(): boolean {
+ return this.#diagnosticErrorsDeferred !== undefined
+ }
+
+ /**
+ * Clear diagnostic state
+ */
+ clearDiagnosticState(): void {
+ this.#diagnosticErrorsDeferred = undefined
+ this.#currentDiagnosticErrors = []
+ this.#selectedDiagnosticFiles.clear()
+ }
+}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/errors.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/errors.ts
index 3038269463..819211dfab 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/errors.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/errors.ts
@@ -11,6 +11,7 @@ type AgenticChatErrorCode =
| 'MCPServerInitTimeout' // mcp server failed to start within allowed time
| 'MCPToolExecTimeout' // mcp tool call failed to complete within allowed time
| 'MCPServerConnectionFailed' // mcp server failed to connect
+ | 'MCPServerAuthFailed' // mcp server failed to complete auth flow
| 'RequestAborted' // request was aborted by the user
| 'RequestThrottled' // request was aborted by the user
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/qAgenticChatServer.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/qAgenticChatServer.ts
index 205e5aff69..445ca78d85 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/qAgenticChatServer.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/qAgenticChatServer.ts
@@ -110,7 +110,7 @@ export const QAgenticChatServer =
)
)
- const userContext = makeUserContextObject(clientParams, runtime.platform, 'CHAT')
+ const userContext = makeUserContextObject(clientParams, runtime.platform, 'CHAT', amazonQServiceManager.serverInfo)
telemetryService.updateUserContext(userContext)
chatController = new AgenticChatController(
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.test.ts
index f4f61f955b..aed5a30643 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.test.ts
@@ -57,7 +57,7 @@ describe('ChatDatabase', () => {
},
} as unknown as Features
- chatDb = new ChatDatabase(mockFeatures)
+ chatDb = ChatDatabase.getInstance(mockFeatures)
})
afterEach(() => {
@@ -665,6 +665,73 @@ describe('ChatDatabase', () => {
)
})
})
+
+ describe('Model Cache Management', () => {
+ beforeEach(async () => {
+ await chatDb.databaseInitialize(0)
+ })
+
+ it('should cache and retrieve models', () => {
+ const models = [{ id: 'model-1', name: 'Test Model' }]
+ const defaultModelId = 'model-1'
+
+ chatDb.setCachedModels(models, defaultModelId)
+ const cached = chatDb.getCachedModels()
+
+ assert.ok(cached, 'Should return cached data')
+ assert.deepStrictEqual(cached.models, models)
+ assert.strictEqual(cached.defaultModelId, defaultModelId)
+ assert.ok(cached.timestamp > 0, 'Should have timestamp')
+ })
+
+ it('should validate cache expiry', () => {
+ const models = [{ id: 'model-1', name: 'Test Model' }]
+ chatDb.setCachedModels(models)
+
+ // Mock isCachedValid to return false (expired)
+ const isCachedValidStub = sinon.stub(util, 'isCachedValid').returns(false)
+
+ assert.strictEqual(chatDb.isCachedModelsValid(), false)
+
+ isCachedValidStub.restore()
+ })
+
+ it('should clear cached models', () => {
+ const models = [{ id: 'model-1', name: 'Test Model' }]
+ chatDb.setCachedModels(models)
+
+ // Verify cache exists
+ assert.ok(chatDb.getCachedModels(), 'Cache should exist before clearing')
+
+ chatDb.clearCachedModels()
+
+ // Verify cache is cleared
+ assert.strictEqual(chatDb.getCachedModels(), undefined, 'Cache should be cleared')
+ })
+
+ it('should clear model cache via static method when instance exists', () => {
+ const models = [{ id: 'model-1', name: 'Test Model' }]
+ chatDb.setCachedModels(models)
+
+ // Verify cache exists
+ assert.ok(chatDb.getCachedModels(), 'Cache should exist before clearing')
+
+ ChatDatabase.clearModelCache()
+
+ // Verify cache is cleared
+ assert.strictEqual(chatDb.getCachedModels(), undefined, 'Cache should be cleared via static method')
+ })
+
+ it('should handle static clearModelCache when no instance exists', () => {
+ // Close current instance
+ chatDb.close()
+
+ // Should not throw when no instance exists
+ assert.doesNotThrow(() => {
+ ChatDatabase.clearModelCache()
+ }, 'Should not throw when no instance exists')
+ })
+ })
})
function uuid(): `${string}-${string}-${string}-${string}-${string}` {
throw new Error('Function not implemented.')
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.ts
index dfcf21308c..f584d63b5f 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/chatDb.ts
@@ -24,16 +24,18 @@ import {
getMd5WorkspaceId,
MessagesWithCharacterCount,
estimateCharacterCountFromImageBlock,
+ isCachedValid,
} from './util'
import * as crypto from 'crypto'
import * as path from 'path'
import { Features } from '@aws/language-server-runtimes/server-interface/server'
-import { ContextCommand, ConversationItemGroup } from '@aws/language-server-runtimes/protocol'
+import { ContextCommand, ConversationItemGroup, Model } from '@aws/language-server-runtimes/protocol'
import { ChatMessage, ToolResultStatus } from '@amzn/codewhisperer-streaming'
import { ChatItemType } from '@aws/mynah-ui'
import { getUserHomeDir } from '@aws/lsp-core/out/util/path'
import { ChatHistoryMaintainer } from './chatHistoryMaintainer'
import { existsSync, renameSync } from 'fs'
+import escapeHTML = require('escape-html')
export class ToolResultValidationError extends Error {
constructor(message?: string) {
@@ -122,6 +124,12 @@ export class ChatDatabase {
return ChatDatabase.#instance
}
+ public static clearModelCache(): void {
+ if (ChatDatabase.#instance) {
+ ChatDatabase.#instance.clearCachedModels()
+ }
+ }
+
public close() {
this.#db.close()
ChatDatabase.#instance = undefined
@@ -686,6 +694,7 @@ export class ChatDatabase {
}
return {
...message,
+ body: escapeHTML(message.body),
userInputMessageContext: {
// keep falcon context when inputMessage is not a toolResult message
editorState: hasToolResults ? undefined : message.userInputMessageContext?.editorState,
@@ -1084,6 +1093,48 @@ export class ChatDatabase {
this.updateSettings({ modelId: modelId === '' ? undefined : modelId })
}
+ getCachedModels(): { models: Model[]; defaultModelId?: string; timestamp: number } | undefined {
+ const settings = this.getSettings()
+ if (settings?.cachedModels && settings?.modelCacheTimestamp) {
+ return {
+ models: settings.cachedModels,
+ defaultModelId: settings.cachedDefaultModelId,
+ timestamp: settings.modelCacheTimestamp,
+ }
+ }
+ return undefined
+ }
+
+ setCachedModels(models: Model[], defaultModelId?: string): void {
+ const currentTimestamp = Date.now()
+ // Get existing settings to preserve fields like modelId
+ const existingSettings = this.getSettings() || { modelId: undefined }
+ this.updateSettings({
+ ...existingSettings,
+ cachedModels: models,
+ cachedDefaultModelId: defaultModelId,
+ modelCacheTimestamp: currentTimestamp,
+ })
+ this.#features.logging.log(`Models cached at timestamp: ${currentTimestamp}`)
+ }
+
+ isCachedModelsValid(): boolean {
+ const cachedData = this.getCachedModels()
+ if (!cachedData) return false
+ return isCachedValid(cachedData.timestamp)
+ }
+
+ clearCachedModels(): void {
+ const existingSettings = this.getSettings() || { modelId: undefined }
+ this.updateSettings({
+ ...existingSettings,
+ cachedModels: undefined,
+ cachedDefaultModelId: undefined,
+ modelCacheTimestamp: undefined,
+ })
+ this.#features.logging.log('Model cache cleared')
+ }
+
getPairProgrammingMode(): boolean | undefined {
const settings = this.getSettings()
return settings?.pairProgrammingMode
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts
index 8f3f46b9f4..e03c90bead 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/chatDb/util.ts
@@ -10,6 +10,7 @@ import {
ConversationItem,
ConversationItemGroup,
IconType,
+ Model,
ReferenceTrackerInformation,
} from '@aws/language-server-runtimes/server-interface'
import {
@@ -27,6 +28,7 @@ import { activeFileCmd } from '../../context/additionalContextProvider'
import { PriorityQueue } from 'typescript-collections'
import { Features } from '@aws/language-server-runtimes/server-interface/server'
import * as crypto from 'crypto'
+import unescapeHTML = require('unescape-html')
// Ported from https://github.com/aws/aws-toolkit-vscode/blob/master/packages/core/src/shared/db/chatDb/util.ts
@@ -84,6 +86,9 @@ export type Rules = {
export type Settings = {
modelId: string | undefined
pairProgrammingMode?: boolean
+ cachedModels?: Model[]
+ cachedDefaultModelId?: string
+ modelCacheTimestamp?: number
}
export type Conversation = {
@@ -131,6 +136,14 @@ export type MessagesWithCharacterCount = {
currentCount: number
}
+export function isCachedValid(timestamp: number): boolean {
+ const currentTime = Date.now()
+ const cacheAge = currentTime - timestamp
+ const CACHE_TTL = 30 * 60 * 1000 // 30 minutes in milliseconds
+
+ return cacheAge < CACHE_TTL
+}
+
/**
* Converts Message to codewhisperer-streaming ChatMessage
*/
@@ -160,7 +173,7 @@ export function messageToStreamingMessage(msg: Message): StreamingMessage {
export function messageToChatMessage(msg: Message): ChatMessage[] {
const chatMessages: ChatMessage[] = [
{
- body: msg.body,
+ body: unescapeHTML(msg.body),
type: msg.type,
codeReference: msg.codeReference,
relatedContent:
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts
index fb6486996e..37d11afe4f 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/fileSearch.ts
@@ -158,3 +158,7 @@ export class FileSearch {
} as const
}
}
+
+export function isFileSearchParams(input: any): input is FileSearchParams {
+ return input && typeof input.path === 'string' && typeof input.queryName === 'string'
+}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts
index cb645eec43..de98f36a09 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
@@ -59,9 +59,7 @@ describe('McpEventHandler error handling', () => {
getConnectionMetadata: sinon.stub().returns({}),
},
runtime: {
- serverInfo: {
- version: '1.0.0',
- },
+ serverInfo: {},
},
}
@@ -100,7 +98,6 @@ describe('McpEventHandler error handling', () => {
errors: mockErrors,
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
@@ -157,7 +154,6 @@ describe('McpEventHandler error handling', () => {
errors: new Map([['errorServer', 'Missing command error']]),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { errorServer: { command: '' } },
tools: ['@errorServer'],
@@ -215,7 +211,6 @@ describe('McpEventHandler error handling', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
@@ -259,7 +254,6 @@ describe('McpEventHandler error handling', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
@@ -292,7 +286,6 @@ describe('McpEventHandler error handling', () => {
errors: mockErrors,
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
@@ -326,7 +319,6 @@ describe('McpEventHandler error handling', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
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 bf8c91b533..d167299106 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts
@@ -3,7 +3,6 @@ import { MCP_SERVER_STATUS_CHANGED, McpManager } from './mcpManager'
import { ChatTelemetryController } from '../../../chat/telemetry/chatTelemetryController'
import { ChokidarFileWatcher } from './chokidarFileWatcher'
// eslint-disable-next-line import/no-nodejs-modules
-import * as path from 'path'
import {
DetailedListGroup,
DetailedListItem,
@@ -13,14 +12,7 @@ import {
Status,
} from '@aws/language-server-runtimes/protocol'
-import {
- getGlobalMcpConfigPath,
- getGlobalAgentConfigPath,
- getWorkspaceMcpConfigPaths,
- getWorkspaceAgentConfigPaths,
- sanitizeName,
- normalizePathFromUri,
-} from './mcpUtils'
+import { getGlobalAgentConfigPath, getWorkspaceAgentConfigPaths, sanitizeName, normalizePathFromUri } from './mcpUtils'
import {
McpPermissionType,
MCPServerConfig,
@@ -29,15 +21,21 @@ import {
McpServerStatus,
} from './mcpTypes'
import { TelemetryService } from '../../../../shared/telemetry/telemetryService'
-import { URI } from 'vscode-uri'
import { ProfileStatusMonitor } from './profileStatusMonitor'
interface PermissionOption {
label: string
value: string
+ description?: string
+}
+
+enum TransportType {
+ STDIO = 'stdio',
+ HTTP = 'http',
}
export class McpEventHandler {
+ private static readonly FILE_WATCH_DEBOUNCE_MS = 2000
#features: Features
#eventListenerRegistered: boolean
#currentEditingServerName: string | undefined
@@ -51,6 +49,12 @@ export class McpEventHandler {
#lastProgrammaticState: boolean = false
#serverNameBeforeUpdate: string | undefined
+ #releaseProgrammaticAfterDebounce(padMs = 500) {
+ setTimeout(() => {
+ this.#isProgrammaticChange = false
+ }, McpEventHandler.FILE_WATCH_DEBOUNCE_MS + padMs)
+ }
+
constructor(features: Features, telemetryService: TelemetryService) {
this.#features = features
this.#eventListenerRegistered = false
@@ -392,7 +396,7 @@ export class McpEventHandler {
const serverStatusError = this.#getServerStatusError(existingValues.name) || {}
// Determine which transport is selected (default to stdio)
- const selectedTransport = existingValues.transport || 'stdio'
+ const selectedTransport = existingValues.transport || TransportType.STDIO
return {
id: params.id,
@@ -431,14 +435,14 @@ export class McpEventHandler {
title: 'Transport',
mandatory: true,
options: [
- { label: 'stdio', value: 'stdio' },
- { label: 'http', value: 'http' },
+ { label: TransportType.STDIO, value: TransportType.STDIO },
+ { label: TransportType.HTTP, value: TransportType.HTTP },
],
value: selectedTransport,
},
]
- if (selectedTransport === 'http') {
+ if (selectedTransport === TransportType.HTTP) {
return [
...common,
{
@@ -602,8 +606,13 @@ export class McpEventHandler {
errors.push('Either command or url is required')
} else if (command && url) {
errors.push('Provide either command OR url, not both')
- } else if (transport && ((transport === 'stdio' && !command) || (transport !== 'stdio' && !url))) {
- errors.push(`${transport === 'stdio' ? 'Command' : 'URL'} is required for ${transport} transport`)
+ } else if (
+ transport &&
+ ((transport === TransportType.STDIO && !command) || (transport !== TransportType.STDIO && !url))
+ ) {
+ errors.push(
+ `${transport === TransportType.STDIO ? 'Command' : 'URL'} is required for ${transport} transport`
+ )
}
if (values.timeout && values.timeout.trim() !== '') {
@@ -691,7 +700,7 @@ export class McpEventHandler {
// stdio‑specific parsing
let args: string[] = []
let env: Record = {}
- if (selectedTransport === 'stdio') {
+ if (selectedTransport === TransportType.STDIO) {
try {
args = (Array.isArray(params.optionsValues.args) ? params.optionsValues.args : [])
.map((item: any) =>
@@ -718,7 +727,7 @@ export class McpEventHandler {
// http‑specific parsing
let headers: Record = {}
- if (selectedTransport === 'http') {
+ if (selectedTransport === TransportType.HTTP) {
try {
const raw = Array.isArray(params.optionsValues.headers) ? params.optionsValues.headers : []
headers = raw.reduce((acc: Record, item: any) => {
@@ -742,7 +751,7 @@ export class McpEventHandler {
// build final config (no transport field persisted)
let config: MCPServerConfig
- if (selectedTransport === 'http') {
+ if (selectedTransport === TransportType.HTTP) {
config = {
url: params.optionsValues.url,
headers,
@@ -785,16 +794,17 @@ export class McpEventHandler {
}
this.#currentEditingServerName = undefined
+ this.#serverNameBeforeUpdate = undefined
// need to check server state now, as there is possibility of error during server initialization
const serverStatusError = this.#getServerStatusError(serverName)
this.#telemetryController?.emitMCPServerInitializeEvent({
source: isEditMode ? 'updateServer' : 'addServer',
- command: selectedTransport === 'stdio' ? params.optionsValues.command : undefined,
- url: selectedTransport === 'http' ? params.optionsValues.url : undefined,
+ command: selectedTransport === TransportType.STDIO ? params.optionsValues.command : undefined,
+ url: selectedTransport === TransportType.HTTP ? params.optionsValues.url : undefined,
enabled: true,
- numTools: McpManager.instance.getAllToolsWithPermissions(serverName).length,
+ numTools: McpManager.instance.getAllToolsWithPermissions(sanitizedServerName).length,
scope: params.optionsValues['scope'] === 'global' ? 'global' : 'workspace',
transportType: selectedTransport,
languageServerVersion: this.#features.runtime.serverInfo.version,
@@ -809,6 +819,7 @@ export class McpEventHandler {
// Stay on add/edit page and show error to user
// Keep isProgrammaticChange true during error handling to prevent file watcher triggers
+ this.#releaseProgrammaticAfterDebounce()
if (isEditMode) {
params.id = 'edit-mcp'
params.title = sanitizedServerName
@@ -823,7 +834,7 @@ export class McpEventHandler {
this.#newlyAddedServers.delete(serverName)
}
- this.#isProgrammaticChange = false
+ this.#releaseProgrammaticAfterDebounce()
// Go to tools permissions page
return this.#handleOpenMcpServer({ id: 'open-mcp-server', title: sanitizedServerName })
@@ -913,25 +924,21 @@ export class McpEventHandler {
}
const mcpManager = McpManager.instance
-
// Get the appropriate agent path
const agentPath = mcpManager.getAllServerConfigs().get(serverName)?.__configPath__
-
- const perm: MCPServerPermission = {
- enabled: true,
- toolPerms: {},
- __configPath__: agentPath,
- }
-
// Set flag to ignore file changes during permission update
this.#isProgrammaticChange = true
try {
+ const perm = mcpManager.getMcpServerPermissions(serverName)!
+ perm.enabled = true
+ perm.__configPath__ = agentPath
await mcpManager.updateServerPermission(serverName, perm)
this.#emitMCPConfigEvent()
+ this.#releaseProgrammaticAfterDebounce()
} catch (error) {
this.#features.logging.error(`Failed to enable MCP server: ${error}`)
- this.#isProgrammaticChange = false
+ this.#releaseProgrammaticAfterDebounce()
}
return { id: params.id }
}
@@ -944,26 +951,21 @@ export class McpEventHandler {
if (!serverName) {
return { id: params.id }
}
-
const mcpManager = McpManager.instance
- // Get the appropriate agent path
+ // Set flag to ignore file changes during permission update
const agentPath = mcpManager.getAllServerConfigs().get(serverName)?.__configPath__
-
- const perm: MCPServerPermission = {
- enabled: false,
- toolPerms: {},
- __configPath__: agentPath,
- }
-
// Set flag to ignore file changes during permission update
this.#isProgrammaticChange = true
-
try {
+ const perm = mcpManager.getMcpServerPermissions(serverName)!
+ perm.enabled = false
+ perm.__configPath__ = agentPath
await mcpManager.updateServerPermission(serverName, perm)
this.#emitMCPConfigEvent()
+ this.#releaseProgrammaticAfterDebounce()
} catch (error) {
this.#features.logging.error(`Failed to disable MCP server: ${error}`)
- this.#isProgrammaticChange = false
+ this.#releaseProgrammaticAfterDebounce()
}
return { id: params.id }
@@ -983,11 +985,11 @@ export class McpEventHandler {
try {
await McpManager.instance.removeServer(serverName)
-
+ this.#releaseProgrammaticAfterDebounce()
return { id: params.id }
} catch (error) {
this.#features.logging.error(`Failed to delete MCP server: ${error}`)
- this.#isProgrammaticChange = false
+ this.#releaseProgrammaticAfterDebounce()
return { id: params.id }
}
}
@@ -1024,7 +1026,7 @@ export class McpEventHandler {
}
// Respect a user flip first; otherwise fall back to what the stored configuration implies.
- const transport = params.optionsValues?.transport ?? (config.url ? 'http' : 'stdio')
+ const transport = params.optionsValues?.transport ?? (config.url ? TransportType.HTTP : TransportType.STDIO)
// Convert stored structures to UI‑friendly lists
const argsList = (config.args ?? []).map(a => ({ arg_key: a })) // for stdio
@@ -1078,17 +1080,16 @@ export class McpEventHandler {
// Add tool select options
toolsWithPermissions.forEach(item => {
const toolName = item.tool.toolName
- const currentPermission = this.#getCurrentPermission(item.permission)
// For Built-in server, use a special function that doesn't include the 'Deny' option
- const permissionOptions = this.#buildPermissionOptions(item.permission)
+ let permissionOptions = this.#buildPermissionOptions()
filterOptions.push({
type: 'select',
id: `${toolName}`,
title: toolName,
description: item.tool.description,
- placeholder: currentPermission,
options: permissionOptions,
+ ...{ value: item.permission, boldTitle: true, mandatory: true, hideMandatoryIcon: true },
})
})
@@ -1101,8 +1102,9 @@ export class McpEventHandler {
// Clean up transport-specific fields
if (optionsValues) {
- const transport = optionsValues.transport ?? 'stdio' // Maintain default to 'stdio'
- const fieldsToDelete = transport === 'http' ? ['command', 'args', 'env_variables'] : ['url', 'headers']
+ const transport = optionsValues.transport ?? TransportType.STDIO // Maintain default to 'stdio'
+ const fieldsToDelete =
+ transport === TransportType.HTTP ? ['command', 'args', 'env_variables'] : ['url', 'headers']
fieldsToDelete.forEach(field => delete optionsValues[field])
}
@@ -1141,20 +1143,22 @@ export class McpEventHandler {
/**
* Builds permission options excluding the current one
*/
- #buildPermissionOptions(currentPermission: string) {
+ #buildPermissionOptions() {
const permissionOptions: PermissionOption[] = []
- if (currentPermission !== McpPermissionType.alwaysAllow) {
- permissionOptions.push({ label: 'Always allow', value: McpPermissionType.alwaysAllow })
- }
+ permissionOptions.push({
+ label: 'Ask',
+ value: McpPermissionType.ask,
+ description: 'Ask for your approval each time this tool is run',
+ })
- if (currentPermission !== McpPermissionType.ask) {
- permissionOptions.push({ label: 'Ask', value: McpPermissionType.ask })
- }
+ permissionOptions.push({
+ label: 'Always allow',
+ value: McpPermissionType.alwaysAllow,
+ description: 'Always allow this tool to run without asking for approval',
+ })
- if (currentPermission !== McpPermissionType.deny) {
- permissionOptions.push({ label: 'Deny', value: McpPermissionType.deny })
- }
+ permissionOptions.push({ label: 'Deny', value: McpPermissionType.deny, description: 'Never run this tool' })
return permissionOptions
}
@@ -1203,6 +1207,7 @@ export class McpEventHandler {
}
const mcpServerPermission = await this.#processPermissionUpdates(
+ serverName,
updatedPermissionConfig,
serverConfig?.__configPath__
)
@@ -1246,11 +1251,11 @@ export class McpEventHandler {
const serverConfig = McpManager.instance.getAllServerConfigs().get(serverName)
if (serverConfig) {
// Emit server initialize event after permission change
- const transportType = serverConfig.command ? 'stdio' : 'http'
+ const transportType = serverConfig.command?.trim() ? TransportType.STDIO : TransportType.HTTP
this.#telemetryController?.emitMCPServerInitializeEvent({
source: 'updatePermission',
- command: transportType === 'stdio' ? serverConfig.command : undefined,
- url: transportType === 'http' ? serverConfig.url : undefined,
+ command: transportType === TransportType.STDIO ? serverConfig.command : undefined,
+ url: transportType === TransportType.HTTP ? serverConfig.url : undefined,
enabled: true,
numTools: McpManager.instance.getAllToolsWithPermissions(serverName).length,
scope:
@@ -1267,10 +1272,11 @@ export class McpEventHandler {
this.#pendingPermissionConfig = undefined
this.#features.logging.info(`Applied permission changes for server: ${serverName}`)
+ this.#releaseProgrammaticAfterDebounce()
return { id: params.id }
} catch (error) {
this.#features.logging.error(`Failed to save MCP permissions: ${error}`)
- this.#isProgrammaticChange = false
+ this.#releaseProgrammaticAfterDebounce()
return { id: params.id }
}
}
@@ -1318,16 +1324,16 @@ export class McpEventHandler {
// Emit server initialize events for all active servers
for (const [serverName, config] of serverConfigs.entries()) {
- const transportType = config.command ? 'stdio' : 'http'
+ const transportType = config.command ? TransportType.STDIO : TransportType.HTTP
const enabled = !mcpManager.isServerDisabled(serverName)
this.#telemetryController?.emitMCPServerInitializeEvent({
source: 'reload',
- command: transportType === 'stdio' ? config.command : undefined,
- url: transportType === 'http' ? config.url : undefined,
+ command: transportType === TransportType.STDIO ? config.command : undefined,
+ url: transportType === TransportType.HTTP ? config.url : undefined,
enabled: enabled,
numTools: mcpManager.getAllToolsWithPermissions(serverName).length,
scope: config.__configPath__ === globalAgentPath ? 'global' : 'workspace',
- transportType: 'stdio',
+ transportType: transportType,
languageServerVersion: this.#features.runtime.serverInfo.version,
})
}
@@ -1400,27 +1406,20 @@ export class McpEventHandler {
/**
* Processes permission updates from the UI
*/
- async #processPermissionUpdates(updatedPermissionConfig: any, agentPath: string | undefined) {
+ async #processPermissionUpdates(serverName: string, updatedPermissionConfig: any, agentPath: string | undefined) {
+ const builtInToolAgentPath = await this.#getAgentPath()
const perm: MCPServerPermission = {
enabled: true,
toolPerms: {},
- __configPath__: agentPath,
+ __configPath__: serverName === 'Built-in' ? builtInToolAgentPath : agentPath,
}
// Process each tool permission setting
for (const [key, val] of Object.entries(updatedPermissionConfig)) {
if (key === 'scope') continue
- // // Get the default permission for this tool from McpManager
- // let defaultPermission = McpManager.instance.getToolPerm(serverName, key)
-
- // // If no default permission is found, use 'alwaysAllow' for Built-in and 'ask' for MCP servers
- // if (!defaultPermission) {
- // defaultPermission = serverName === 'Built-in' ? 'alwaysAllow' : 'ask'
- // }
-
- // If the value is an empty string (''), skip this tool to preserve its existing permission in the persona file
- if (val === '') continue
+ const currentPerm = McpManager.instance.getToolPerm(serverName, key)
+ if (val === currentPerm) continue
switch (val) {
case McpPermissionType.alwaysAllow:
perm.toolPerms[key] = McpPermissionType.alwaysAllow
@@ -1442,7 +1441,8 @@ export class McpEventHandler {
*/
#getServerStatusError(serverName: string): { title: string; icon: string; status: Status } | undefined {
const serverStates = McpManager.instance.getAllServerStates()
- const serverState = serverStates.get(serverName)
+ const key = serverName ? sanitizeName(serverName) : serverName
+ const serverState = key ? serverStates.get(key) : undefined
if (!serverState) {
return undefined
@@ -1506,11 +1506,10 @@ export class McpEventHandler {
if (!this.#lastProgrammaticState) {
await this.#handleRefreshMCPList({ id: 'refresh-mcp-list' })
} else {
- this.#isProgrammaticChange = false
this.#features.logging.debug('Skipping refresh due to programmatic change')
}
this.#debounceTimer = null
- }, 2000)
+ }, McpEventHandler.FILE_WATCH_DEBOUNCE_MS)
})
}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts
index 7b239abad0..1b5941a398 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
@@ -37,7 +37,6 @@ function stubAgentConfig(): sinon.SinonStub {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
@@ -157,7 +156,6 @@ describe('callTool()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { s1: disabledCfg },
tools: ['@s1'],
@@ -184,7 +182,6 @@ describe('callTool()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { s1: enabledCfg },
tools: ['@s1'],
@@ -210,7 +207,6 @@ describe('callTool()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { s1: timeoutCfg },
tools: ['@s1'],
@@ -239,12 +235,12 @@ describe('callTool()', () => {
describe('addServer()', () => {
let loadStub: sinon.SinonStub
let initOneStub: sinon.SinonStub
- let saveAgentConfigStub: sinon.SinonStub
+ let saveServerSpecificAgentConfigStub: sinon.SinonStub
beforeEach(() => {
loadStub = stubAgentConfig()
initOneStub = stubInitOneServer()
- saveAgentConfigStub = sinon.stub(mcpUtils, 'saveAgentConfig').resolves()
+ saveServerSpecificAgentConfigStub = sinon.stub(mcpUtils, 'saveServerSpecificAgentConfig').resolves()
})
afterEach(async () => {
@@ -268,7 +264,7 @@ describe('addServer()', () => {
await mgr.addServer('newS', newCfg, 'path.json')
- expect(saveAgentConfigStub.calledOnce).to.be.true
+ expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true
expect(initOneStub.calledOnceWith('newS', sinon.match(newCfg))).to.be.true
})
@@ -279,7 +275,6 @@ describe('addServer()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
@@ -301,14 +296,14 @@ describe('addServer()', () => {
await mgr.addServer('httpSrv', httpCfg, 'http.json')
- expect(saveAgentConfigStub.calledOnce).to.be.true
+ expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true
expect(initOneStub.calledOnceWith('httpSrv', sinon.match(httpCfg))).to.be.true
})
})
describe('removeServer()', () => {
let loadStub: sinon.SinonStub
- let saveAgentConfigStub: sinon.SinonStub
+ let saveServerSpecificAgentConfigStub: sinon.SinonStub
let existsStub: sinon.SinonStub
let readFileStub: sinon.SinonStub
let writeFileStub: sinon.SinonStub
@@ -318,7 +313,7 @@ describe('removeServer()', () => {
beforeEach(() => {
loadStub = stubAgentConfig()
- saveAgentConfigStub = sinon.stub(mcpUtils, 'saveAgentConfig').resolves()
+ saveServerSpecificAgentConfigStub = sinon.stub(mcpUtils, 'saveServerSpecificAgentConfig').resolves()
existsStub = sinon.stub(fakeWorkspace.fs, 'exists').resolves(true)
readFileStub = sinon
.stub(fakeWorkspace.fs, 'readFile')
@@ -353,7 +348,6 @@ describe('removeServer()', () => {
;(mgr as any).serverNameMapping.set('x', 'x')
;(mgr as any).agentConfig = {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { x: {} },
tools: ['@x'],
@@ -364,11 +358,11 @@ describe('removeServer()', () => {
}
await mgr.removeServer('x')
- expect(saveAgentConfigStub.calledOnce).to.be.true
+ expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true
expect((mgr as any).clients.has('x')).to.be.false
})
- it('removes server from all config files', async () => {
+ it('removes server from agent config', async () => {
const mgr = await McpManager.init([], features)
const dummy = new Client({ name: 'c', version: 'v' })
;(mgr as any).clients.set('x', dummy)
@@ -383,7 +377,6 @@ describe('removeServer()', () => {
;(mgr as any).serverNameMapping.set('x', 'x')
;(mgr as any).agentConfig = {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { x: {} },
tools: ['@x'],
@@ -395,14 +388,13 @@ describe('removeServer()', () => {
await mgr.removeServer('x')
- // Verify that writeFile was called for each config path (2 workspace + 1 global)
- expect(writeFileStub.callCount).to.equal(3)
+ // Verify that saveServerSpecificAgentConfig was called
+ expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true
+ expect((mgr as any).clients.has('x')).to.be.false
- // Verify the content of the writes (should have removed the server)
- writeFileStub.getCalls().forEach(call => {
- const content = JSON.parse(call.args[1])
- expect(content.mcpServers).to.not.have.property('x')
- })
+ // Verify server was removed from agent config
+ expect((mgr as any).agentConfig.mcpServers).to.not.have.property('x')
+ expect((mgr as any).agentConfig.tools).to.not.include('@x')
})
})
@@ -473,11 +465,11 @@ describe('mutateConfigFile()', () => {
describe('updateServer()', () => {
let loadStub: sinon.SinonStub
let initOneStub: sinon.SinonStub
- let saveAgentConfigStub: sinon.SinonStub
+ let saveServerSpecificAgentConfigStub: sinon.SinonStub
beforeEach(() => {
initOneStub = stubInitOneServer()
- saveAgentConfigStub = sinon.stub(mcpUtils, 'saveAgentConfig').resolves()
+ saveServerSpecificAgentConfigStub = sinon.stub(mcpUtils, 'saveServerSpecificAgentConfig').resolves()
})
afterEach(async () => {
@@ -502,7 +494,6 @@ describe('updateServer()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { u1: oldCfg },
tools: ['@u1'],
@@ -520,11 +511,11 @@ describe('updateServer()', () => {
const closeStub = sinon.stub(fakeClient, 'close').resolves()
initOneStub.resetHistory()
- saveAgentConfigStub.resetHistory()
+ saveServerSpecificAgentConfigStub.resetHistory()
await mgr.updateServer('u1', { timeout: 999 }, 'u.json')
- expect(saveAgentConfigStub.calledOnce).to.be.true
+ expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true
expect(closeStub.calledOnce).to.be.true
expect(initOneStub.calledOnceWith('u1', sinon.match.has('timeout', 999))).to.be.true
})
@@ -545,7 +536,6 @@ describe('updateServer()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { srv: oldCfg },
tools: ['@srv'],
@@ -560,11 +550,11 @@ describe('updateServer()', () => {
const mgr = McpManager.instance
initOneStub.resetHistory()
- saveAgentConfigStub.resetHistory()
+ saveServerSpecificAgentConfigStub.resetHistory()
await mgr.updateServer('srv', { command: undefined, url: 'https://new.host/mcp' }, 'z.json')
- expect(saveAgentConfigStub.calledOnce).to.be.true
+ expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true
expect(initOneStub.calledOnceWith('srv', sinon.match({ url: 'https://new.host/mcp' }))).to.be.true
})
})
@@ -599,7 +589,6 @@ describe('requiresApproval()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { s: cfg },
tools: ['@s'],
@@ -640,7 +629,6 @@ describe('getAllServerConfigs()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { srv: cfg },
tools: ['@srv'],
@@ -688,7 +676,6 @@ describe('getServerState()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { srv: cfg },
tools: ['@srv'],
@@ -730,7 +717,6 @@ describe('getAllServerStates()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { srv: cfg },
tools: ['@srv'],
@@ -780,7 +766,6 @@ describe('getEnabledTools()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { srv: cfg },
tools: ['@srv'],
@@ -798,7 +783,6 @@ describe('getEnabledTools()', () => {
if (!(mgr as any).agentConfig) {
;(mgr as any).agentConfig = {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
@@ -829,7 +813,6 @@ describe('getEnabledTools()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { srv: disabledCfg },
tools: ['@srv'],
@@ -867,7 +850,6 @@ describe('getAllToolsWithPermissions()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { s1: cfg },
tools: ['@s1'],
@@ -932,7 +914,6 @@ describe('isServerDisabled()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { srv: disabledCfg },
tools: ['@srv'],
@@ -963,7 +944,6 @@ describe('isServerDisabled()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { srv: enabledCfg },
tools: ['@srv'],
@@ -993,7 +973,6 @@ describe('isServerDisabled()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { srv: undefinedCfg },
tools: ['@srv'],
@@ -1062,9 +1041,11 @@ describe('listServersAndTools()', () => {
describe('updateServerPermission()', () => {
let saveAgentConfigStub: sinon.SinonStub
+ let saveServerSpecificAgentConfigStub: sinon.SinonStub
beforeEach(() => {
saveAgentConfigStub = sinon.stub(mcpUtils, 'saveAgentConfig').resolves()
+ saveServerSpecificAgentConfigStub = sinon.stub(mcpUtils, 'saveServerSpecificAgentConfig').resolves()
})
afterEach(async () => {
@@ -1090,7 +1071,6 @@ describe('updateServerPermission()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { srv: cfg },
tools: ['@srv'],
@@ -1113,8 +1093,8 @@ describe('updateServerPermission()', () => {
__configPath__: '/p',
})
- // Verify saveAgentConfig was called
- expect(saveAgentConfigStub.calledOnce).to.be.true
+ // Verify saveServerSpecificAgentConfig was called
+ expect(saveServerSpecificAgentConfigStub.calledOnce).to.be.true
// Verify the tool permission was updated
expect(mgr.requiresApproval('srv', 'tool1')).to.be.false
@@ -1155,7 +1135,6 @@ describe('reinitializeMcpServers()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { srvA: cfg1 },
tools: ['@srvA'],
@@ -1172,7 +1151,6 @@ describe('reinitializeMcpServers()', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: { srvB: cfg2 },
tools: ['@srvB'],
@@ -1278,7 +1256,6 @@ describe('concurrent server initialization', () => {
const serversMap = new Map(Object.entries(serverConfigs))
const agentConfig = {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: Object.fromEntries(Object.entries(serverConfigs)),
tools: Object.keys(serverConfigs).map(name => `@${name}`),
@@ -1423,7 +1400,6 @@ describe('McpManager error handling', () => {
errors: mockErrors,
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
@@ -1451,7 +1427,6 @@ describe('McpManager error handling', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
@@ -1477,7 +1452,6 @@ describe('McpManager error handling', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
@@ -1520,7 +1494,6 @@ describe('McpManager error handling', () => {
errors: new Map([['file1.json', 'Initial error']]),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
@@ -1538,7 +1511,6 @@ describe('McpManager error handling', () => {
errors: new Map(),
agentConfig: {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
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 04f827ff2c..351397b701 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts
@@ -26,6 +26,7 @@ import {
isEmptyEnv,
loadAgentConfig,
saveAgentConfig,
+ saveServerSpecificAgentConfig,
sanitizeName,
getGlobalAgentConfigPath,
getWorkspaceMcpConfigPaths,
@@ -37,9 +38,15 @@ import { Mutex } from 'async-mutex'
import path = require('path')
import { URI } from 'vscode-uri'
import { sanitizeInput } from '../../../../shared/utils'
+import { ProfileStatusMonitor } from './profileStatusMonitor'
+import { OAuthClient } from './mcpOauthClient'
export const MCP_SERVER_STATUS_CHANGED = 'mcpServerStatusChanged'
export const AGENT_TOOLS_CHANGED = 'agentToolsChanged'
+export enum AuthIntent {
+ Interactive = 'interactive',
+ Silent = 'silent',
+}
/**
* Manages MCP servers and their tools
@@ -85,8 +92,15 @@ export class McpManager {
if (!McpManager.#instance) {
const mgr = new McpManager(agentPaths, features)
McpManager.#instance = mgr
- await mgr.discoverAllServers()
- features.logging.info(`MCP: discovered ${mgr.mcpTools.length} tools across all servers`)
+
+ const shouldDiscoverServers = ProfileStatusMonitor.getMcpState()
+
+ if (shouldDiscoverServers) {
+ await mgr.discoverAllServers()
+ features.logging.info(`MCP: discovered ${mgr.mcpTools.length} tools across all servers`)
+ } else {
+ features.logging.info('MCP: initialized without server discovery')
+ }
// Emit MCP configuration metrics
const serverConfigs = mgr.getAllServerConfigs()
@@ -178,14 +192,52 @@ export class McpManager {
// Reset permissions map
this.mcpServerPermissions.clear()
-
- // Initialize permissions for servers from agent config
+ // Create init state
for (const [sanitizedName, _] of this.mcpServers.entries()) {
- const name = this.serverNameMapping.get(sanitizedName) || sanitizedName
-
// Set server status to UNINITIALIZED initially
this.setState(sanitizedName, McpServerStatus.UNINITIALIZED, 0)
+ }
+ // Get all servers that need to be initialized
+ const serversToInit: Array<[string, MCPServerConfig]> = []
+
+ for (const [name, cfg] of this.mcpServers.entries()) {
+ if (this.isServerDisabled(name)) {
+ this.features.logging.info(`MCP: server '${name}' is disabled by persona settings, skipping`)
+ this.setState(name, McpServerStatus.DISABLED, 0)
+ this.emitToolsChanged(name)
+ continue
+ }
+ serversToInit.push([name, cfg])
+ }
+
+ // Process servers in batches of 5 at a time
+ const MAX_CONCURRENT_SERVERS = 5
+ const totalServers = serversToInit.length
+
+ if (totalServers > 0) {
+ this.features.logging.info(
+ `MCP: initializing ${totalServers} servers with max concurrency of ${MAX_CONCURRENT_SERVERS}`
+ )
+
+ // Process servers in batches
+ for (let i = 0; i < totalServers; i += MAX_CONCURRENT_SERVERS) {
+ const batch = serversToInit.slice(i, i + MAX_CONCURRENT_SERVERS)
+ const batchPromises = batch.map(([name, cfg]) => this.initOneServer(name, cfg, AuthIntent.Silent))
+ this.features.logging.debug(
+ `MCP: initializing batch of ${batch.length} servers (${i + 1}-${Math.min(i + MAX_CONCURRENT_SERVERS, totalServers)} of ${totalServers})`
+ )
+ await Promise.all(batchPromises)
+ }
+
+ this.features.logging.info(`MCP: completed initialization of ${totalServers} servers`)
+ } else {
+ // Emit event to refresh MCP list page when no servers are configured
+ this.setState('no-servers', McpServerStatus.UNINITIALIZED, 0)
+ }
+
+ for (const [sanitizedName, _] of this.mcpServers.entries()) {
+ const name = this.serverNameMapping.get(sanitizedName) || sanitizedName
// Initialize permissions for this server
const serverPrefix = `@${name}`
@@ -208,9 +260,18 @@ export class McpManager {
})
} else {
// Only specific tools are enabled
+ // get allTools of this server, if it's not in tools --> it's denied
+ // have to move the logic after all servers finish init, because that's when we have list of tools
+ const deniedTools = new Set(
+ this.getAllTools()
+ .filter(tool => tool.serverName === name)
+ .map(tool => tool.toolName)
+ )
this.agentConfig.tools.forEach(tool => {
if (tool.startsWith(serverPrefix + '/')) {
+ // remove this from deniedTools
const toolName = tool.substring(serverPrefix.length + 1)
+ deniedTools.delete(toolName)
if (toolName) {
// Check if tool is in allowedTools
if (this.agentConfig.allowedTools.includes(tool)) {
@@ -221,6 +282,11 @@ export class McpManager {
}
}
})
+
+ // update permission to deny for rest of the tools
+ deniedTools.forEach(tool => {
+ toolPerms[tool] = McpPermissionType.deny
+ })
}
this.mcpServerPermissions.set(sanitizedName, {
@@ -228,57 +294,25 @@ export class McpManager {
toolPerms,
})
}
-
- // Get all servers that need to be initialized
- const serversToInit: Array<[string, MCPServerConfig]> = []
-
- for (const [name, cfg] of this.mcpServers.entries()) {
- if (this.isServerDisabled(name)) {
- this.features.logging.info(`MCP: server '${name}' is disabled by persona settings, skipping`)
- this.setState(name, McpServerStatus.DISABLED, 0)
- this.emitToolsChanged(name)
- continue
- }
- serversToInit.push([name, cfg])
- }
-
- // Process servers in batches of 5 at a time
- const MAX_CONCURRENT_SERVERS = 5
- const totalServers = serversToInit.length
-
- if (totalServers > 0) {
- this.features.logging.info(
- `MCP: initializing ${totalServers} servers with max concurrency of ${MAX_CONCURRENT_SERVERS}`
- )
-
- // Process servers in batches
- for (let i = 0; i < totalServers; i += MAX_CONCURRENT_SERVERS) {
- const batch = serversToInit.slice(i, i + MAX_CONCURRENT_SERVERS)
- const batchPromises = batch.map(([name, cfg]) => this.initOneServer(name, cfg))
-
- this.features.logging.debug(
- `MCP: initializing batch of ${batch.length} servers (${i + 1}-${Math.min(i + MAX_CONCURRENT_SERVERS, totalServers)} of ${totalServers})`
- )
- await Promise.all(batchPromises)
- }
-
- this.features.logging.info(`MCP: completed initialization of ${totalServers} servers`)
- }
}
/**
* Start a server process, connect client, and register its tools.
* Errors are logged but do not stop discovery of other servers.
*/
- private async initOneServer(serverName: string, cfg: MCPServerConfig): Promise {
- const DEFAULT_SERVER_INIT_TIMEOUT_MS = 60_000
+ private async initOneServer(
+ serverName: string,
+ cfg: MCPServerConfig,
+ authIntent: AuthIntent = AuthIntent.Silent
+ ): Promise {
+ const DEFAULT_SERVER_INIT_TIMEOUT_MS = 120_000
this.setState(serverName, McpServerStatus.INITIALIZING, 0)
try {
this.features.logging.debug(`MCP: initializing server [${serverName}]`)
const client = new Client({
- name: `mcp-client-${serverName}`,
+ name: `q-chat-plugin`, // Do not use server name in the client name to avoid polluting builder-mcp metrics
version: '1.0.0',
})
@@ -286,6 +320,7 @@ export class McpManager {
const isStdio = !!cfg.command
const doConnect = async () => {
if (isStdio) {
+ // stdio transport
const mergedEnv = {
...(process.env as Record),
// Make sure we do not have empty key and value in mergedEnv, or adding server through UI will fail on Windows
@@ -326,11 +361,52 @@ export class McpManager {
)
}
} else {
+ // streamable http/SSE transport
const base = new URL(cfg.url!)
try {
+ // Use HEAD to check if it needs OAuth
+ let headers: Record = { ...(cfg.headers ?? {}) }
+ let needsOAuth = false
+ try {
+ const headResp = await fetch(base, { method: 'HEAD', headers })
+ const www = headResp.headers.get('www-authenticate') || ''
+ needsOAuth = headResp.status === 401 || headResp.status === 403 || /bearer/i.test(www)
+ } catch {
+ this.features.logging.info(`MCP: HEAD not available`)
+ }
+
+ if (needsOAuth) {
+ OAuthClient.initialize(this.features.workspace, this.features.logging, this.features.lsp)
+ try {
+ const bearer = await OAuthClient.getValidAccessToken(base, {
+ interactive: authIntent === AuthIntent.Interactive,
+ })
+ if (bearer) {
+ headers = { ...headers, Authorization: `Bearer ${bearer}` }
+ } else if (authIntent === AuthIntent.Silent) {
+ throw new AgenticChatError(
+ `Server '${serverName}' requires OAuth. Click on Save to reauthenticate.`,
+ 'MCPServerAuthFailed'
+ )
+ }
+ } catch (e: any) {
+ const msg = e?.message || ''
+ const short = /authorization_timed_out/i.test(msg)
+ ? 'Sign-in timed out. Please try again.'
+ : /Authorization error|PKCE|access_denied|login|consent|token exchange failed/i.test(
+ msg
+ )
+ ? 'Sign-in was cancelled or failed. Please try again.'
+ : `OAuth failed: ${msg}`
+
+ throw new AgenticChatError(`MCP: ${short}`, 'MCPServerAuthFailed')
+ }
+ }
+
try {
// try streamable http first
- transport = new StreamableHTTPClientTransport(base, this.buildHttpOpts(cfg.headers))
+ transport = new StreamableHTTPClientTransport(base, this.buildHttpOpts(headers))
+
this.features.logging.info(`MCP: Connecting MCP server using StreamableHTTPClientTransport`)
await client.connect(transport)
} catch (err) {
@@ -338,13 +414,14 @@ export class McpManager {
this.features.logging.info(
`MCP: streamable http connect failed for [${serverName}], fallback to SSEClientTransport: ${String(err)}`
)
- transport = new SSEClientTransport(new URL(cfg.url!), this.buildSseOpts(cfg.headers))
+ transport = new SSEClientTransport(new URL(cfg.url!), this.buildSseOpts(headers))
await client.connect(transport)
}
} catch (err: any) {
let errorMessage = err?.message ?? String(err)
+ const oauthHint = /oauth/i.test(errorMessage) ? ' (OAuth)' : ''
throw new AgenticChatError(
- `MCP: server '${serverName}' failed to connect: ${errorMessage}`,
+ `MCP: server '${serverName}' failed to connect${oauthHint}: ${errorMessage}`,
'MCPServerConnectionFailed'
)
}
@@ -624,7 +701,7 @@ export class McpManager {
disabled: cfg.disabled ?? false,
}
// Only add timeout to agent config if it's not 0
- if (cfg.timeout !== 0) {
+ if (cfg.timeout !== undefined) {
serverConfig.timeout = cfg.timeout
}
if (cfg.args && cfg.args.length > 0) {
@@ -657,17 +734,26 @@ export class McpManager {
this.agentConfig.tools.push(serverPrefix)
}
- // Save agent config once with all changes
- await saveAgentConfig(
+ // Save server-specific changes to agent config
+ const serverTools = this.agentConfig.tools.filter(
+ tool => tool === serverPrefix || tool.startsWith(`${serverPrefix}/`)
+ )
+ const serverAllowedTools = this.agentConfig.allowedTools.filter(
+ tool => tool === serverPrefix || tool.startsWith(`${serverPrefix}/`)
+ )
+
+ await saveServerSpecificAgentConfig(
this.features.workspace,
this.features.logging,
- this.agentConfig,
- agentPath,
- serverName
+ serverName,
+ serverConfig,
+ serverTools,
+ serverAllowedTools,
+ agentPath
)
// Add server tools to tools list after initialization
- await this.initOneServer(sanitizedName, newCfg)
+ await this.initOneServer(sanitizedName, newCfg, AuthIntent.Interactive)
} catch (err) {
this.features.logging.error(
`Failed to add MCP server '${serverName}': ${err instanceof Error ? err.message : String(err)}`
@@ -727,38 +813,16 @@ export class McpManager {
return true
})
- // Save agent config
- await saveAgentConfig(
+ // Save server removal to agent config
+ await saveServerSpecificAgentConfig(
this.features.workspace,
this.features.logging,
- this.agentConfig,
- cfg.__configPath__,
- unsanitizedName
+ unsanitizedName,
+ null, // null indicates server should be removed
+ [],
+ [],
+ cfg.__configPath__
)
-
- // Get all config paths and delete the server from each one
- const wsUris = this.features.workspace.getAllWorkspaceFolders()?.map(f => f.uri) ?? []
- const wsConfigPaths = getWorkspaceMcpConfigPaths(wsUris)
- const globalConfigPath = getGlobalMcpConfigPath(this.features.workspace.fs.getUserHomeDir())
- const allConfigPaths = [...wsConfigPaths, globalConfigPath]
-
- // Delete the server from all config files
- for (const configPath of allConfigPaths) {
- try {
- await this.mutateConfigFile(configPath, json => {
- if (json.mcpServers && json.mcpServers[unsanitizedName]) {
- delete json.mcpServers[unsanitizedName]
- this.features.logging.info(
- `Deleted server '${unsanitizedName}' from config file: ${configPath}`
- )
- }
- })
- } catch (err) {
- this.features.logging.warn(
- `Failed to delete server '${unsanitizedName}' from config file ${configPath}: ${err}`
- )
- }
- }
}
this.mcpServers.delete(serverName)
@@ -816,13 +880,23 @@ export class McpManager {
}
this.agentConfig.mcpServers[unsanitizedServerName] = updatedConfig
- // Save agent config
- await saveAgentConfig(
+ // Save server-specific changes to agent config
+ const serverPrefix = `@${unsanitizedServerName}`
+ const serverTools = this.agentConfig.tools.filter(
+ tool => tool === serverPrefix || tool.startsWith(`${serverPrefix}/`)
+ )
+ const serverAllowedTools = this.agentConfig.allowedTools.filter(
+ tool => tool === serverPrefix || tool.startsWith(`${serverPrefix}/`)
+ )
+
+ await saveServerSpecificAgentConfig(
this.features.workspace,
this.features.logging,
- this.agentConfig,
- agentPath,
- unsanitizedServerName
+ unsanitizedServerName,
+ updatedConfig,
+ serverTools,
+ serverAllowedTools,
+ agentPath
)
}
@@ -844,7 +918,7 @@ export class McpManager {
this.setState(serverName, McpServerStatus.DISABLED, 0)
this.emitToolsChanged(serverName)
} else {
- await this.initOneServer(serverName, newCfg)
+ await this.initOneServer(serverName, newCfg, AuthIntent.Interactive)
}
} catch (err) {
this.handleError(serverName, err)
@@ -870,8 +944,7 @@ export class McpManager {
this.mcpServers.clear()
this.mcpServerStates.clear()
this.agentConfig = {
- name: 'default-agent',
- version: '1.0.0',
+ name: 'amazon_q_default',
description: 'Agent configuration',
mcpServers: {},
tools: [],
@@ -901,7 +974,11 @@ export class McpManager {
// Restore the saved tool name mapping
this.setToolNameMapping(savedToolNameMapping)
- await this.discoverAllServers()
+ const shouldDiscoverServers = ProfileStatusMonitor.getMcpState()
+
+ if (shouldDiscoverServers) {
+ await this.discoverAllServers()
+ }
const reinitializedServerCount = McpManager.#instance?.mcpServers.size
this.features.logging.info(
@@ -1022,6 +1099,12 @@ export class McpManager {
}
}
+ // Update mcpServerPermissions map immediately to reflect changes
+ this.mcpServerPermissions.set(serverName, {
+ enabled: perm.enabled,
+ toolPerms: perm.toolPerms || {},
+ })
+
// Update server enabled/disabled state in agent config
if (this.agentConfig.mcpServers[unsanitizedServerName]) {
this.agentConfig.mcpServers[unsanitizedServerName].disabled = !perm.enabled
@@ -1032,24 +1115,29 @@ export class McpManager {
serverConfig.disabled = !perm.enabled
}
- // Save agent config
+ // Save only server-specific changes to agent config
const agentPath = perm.__configPath__
if (agentPath) {
- await saveAgentConfig(
+ // Collect server-specific tools and allowedTools
+ const serverPrefix = `@${unsanitizedServerName}`
+ const serverTools = this.agentConfig.tools.filter(
+ tool => tool === serverPrefix || tool.startsWith(`${serverPrefix}/`)
+ )
+ const serverAllowedTools = this.agentConfig.allowedTools.filter(
+ tool => tool === serverPrefix || tool.startsWith(`${serverPrefix}/`)
+ )
+
+ await saveServerSpecificAgentConfig(
this.features.workspace,
this.features.logging,
- this.agentConfig,
- agentPath,
- unsanitizedServerName
+ unsanitizedServerName,
+ this.agentConfig.mcpServers[unsanitizedServerName],
+ serverTools,
+ serverAllowedTools,
+ agentPath
)
}
- // Update mcpServerPermissions map
- this.mcpServerPermissions.set(serverName, {
- enabled: perm.enabled,
- toolPerms: perm.toolPerms || {},
- })
-
// enable/disable server
if (this.isServerDisabled(serverName)) {
const client = this.clients.get(serverName)
@@ -1059,8 +1147,8 @@ export class McpManager {
}
this.setState(serverName, McpServerStatus.DISABLED, 0)
} else {
- if (!this.clients.has(serverName)) {
- await this.initOneServer(serverName, this.mcpServers.get(serverName)!)
+ if (!this.clients.has(serverName) && serverName !== 'Built-in') {
+ await this.initOneServer(serverName, this.mcpServers.get(serverName)!, AuthIntent.Silent)
}
}
@@ -1087,6 +1175,13 @@ export class McpManager {
return !this.agentConfig.allowedTools.includes(toolId)
}
+ /**
+ * get server's tool permission
+ */
+ public getMcpServerPermissions(serverName: string): MCPServerPermission | undefined {
+ return this.mcpServerPermissions.get(serverName)
+ }
+
/**
* Returns any errors that occurred during loading of MCP configuration files
*/
@@ -1106,7 +1201,8 @@ export class McpManager {
*/
public async removeServerFromConfigFile(serverName: string): Promise {
try {
- const cfg = this.mcpServers.get(serverName)
+ const sanitized = sanitizeName(serverName)
+ const cfg = this.mcpServers.get(sanitized)
if (!cfg || !cfg.__configPath__) {
this.features.logging.warn(
`Cannot remove config for server '${serverName}': Config not found or missing path`
@@ -1114,7 +1210,7 @@ export class McpManager {
return
}
- const unsanitizedName = this.serverNameMapping.get(serverName) || serverName
+ const unsanitizedName = this.serverNameMapping.get(sanitized) || serverName
// Remove from agent config
if (unsanitizedName && this.agentConfig) {
@@ -1147,13 +1243,15 @@ export class McpManager {
return true
})
- // Save agent config
- await saveAgentConfig(
+ // Save server removal to agent config
+ await saveServerSpecificAgentConfig(
this.features.workspace,
this.features.logging,
- this.agentConfig,
- cfg.__configPath__,
- unsanitizedName
+ unsanitizedName,
+ null, // null indicates server should be removed
+ [],
+ [],
+ cfg.__configPath__
)
}
} catch (err) {
@@ -1261,11 +1359,21 @@ export class McpManager {
private handleError(server: string | undefined, err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
- this.features.logging.error(`MCP ERROR${server ? ` [${server}]` : ''}: ${msg}`)
+ const isBenignSseDisconnect =
+ /SSE error:\s*TypeError:\s*terminated:\s*Body Timeout Error/i.test(msg) ||
+ /TypeError:\s*terminated:\s*Body Timeout Error/i.test(msg) ||
+ /TypeError:\s*terminated:\s*other side closed/i.test(msg) ||
+ /ECONNRESET|ENETRESET|EPIPE/i.test(msg)
- if (server) {
- this.setState(server, McpServerStatus.FAILED, 0, msg)
- this.emitToolsChanged(server)
+ if (isBenignSseDisconnect) {
+ this.features.logging.debug(`MCP SSE idle timeout${server ? ` [${server}]` : ''}: ${msg}`)
+ } else {
+ // default path for real errors
+ this.features.logging.error(`MCP ERROR${server ? ` [${server}]` : ''}: ${msg}`)
+ if (server) {
+ this.setState(server, McpServerStatus.FAILED, 0, msg)
+ this.emitToolsChanged(server)
+ }
}
}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.test.ts
new file mode 100644
index 0000000000..ea711319a5
--- /dev/null
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.test.ts
@@ -0,0 +1,129 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates.
+ * All Rights Reserved. SPDX-License-Identifier: Apache-2.0
+ */
+
+import { expect } from 'chai'
+import * as sinon from 'sinon'
+import * as crypto from 'crypto'
+import * as http from 'http'
+import { EventEmitter } from 'events'
+import * as path from 'path'
+import { OAuthClient } from './mcpOauthClient'
+
+const fakeLogger = {
+ log: () => {},
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+}
+
+const fakeLsp = {
+ window: {
+ showDocument: sinon.stub().resolves({ success: true }),
+ },
+} as any
+
+const fakeWorkspace = {
+ fs: {
+ exists: async (_path: string) => false,
+ readFile: async (_path: string) => Buffer.from('{}'),
+ writeFile: async (_path: string, _d: any) => {},
+ mkdir: async (_dir: string, _opts: any) => {},
+ },
+} as any
+
+function stubFileSystem(tokenObj?: any, regObj?: any): void {
+ const cacheDir = (OAuthClient as any).cacheDir as string
+ const tokPath = path.join(cacheDir, 'testkey.token.json')
+ const regPath = path.join(cacheDir, 'testkey.registration.json')
+
+ const existsStub = sinon.stub(fakeWorkspace.fs, 'exists')
+ existsStub.callsFake(async (p: any) => {
+ if (p === tokPath && tokenObj) return true
+ if (p === regPath && regObj) return true
+ return false
+ })
+
+ const readStub = sinon.stub(fakeWorkspace.fs, 'readFile')
+ readStub.callsFake(async (p: any) => {
+ if (p === tokPath && tokenObj) return Buffer.from(JSON.stringify(tokenObj))
+ if (p === regPath && regObj) return Buffer.from(JSON.stringify(regObj))
+ return Buffer.from('{}')
+ })
+
+ sinon.stub(fakeWorkspace.fs, 'writeFile').resolves()
+ sinon.stub(fakeWorkspace.fs, 'mkdir').resolves()
+}
+
+function stubHttpServer(): void {
+ sinon.stub(http, 'createServer').callsFake(() => {
+ const srv = new EventEmitter() as unknown as http.Server & EventEmitter
+ ;(srv as any).address = () => ({ address: '127.0.0.1', port: 12345, family: 'IPv4' })
+ ;(srv as any).listen = (_port?: any, _host?: any, _backlog?: any, cb?: any) => {
+ if (typeof cb === 'function') cb()
+ // simulate async readiness like a real server
+ process.nextTick(() => srv.emit('listening'))
+ return srv
+ }
+ ;(srv as any).close = (cb?: any) => {
+ if (typeof cb === 'function') cb()
+ srv.removeAllListeners()
+ return srv
+ }
+ return srv
+ })
+}
+
+describe('OAuthClient helpers', () => {
+ it('computeKey() generates deterministic SHA-256 hex', () => {
+ const url = new URL('https://example.com/api')
+ const expected = crypto
+ .createHash('sha256')
+ .update(url.origin + url.pathname)
+ .digest('hex')
+ const actual = (OAuthClient as any).computeKey(url)
+ expect(actual).to.equal(expected)
+ })
+
+ it('b64url() strips padding and is URL-safe', () => {
+ const buf = Buffer.from('hello')
+ const actual = (OAuthClient as any).b64url(buf)
+ expect(actual).to.equal('aGVsbG8')
+ })
+})
+
+describe('OAuthClient getValidAccessToken()', () => {
+ const now = Date.now()
+
+ beforeEach(() => {
+ sinon.restore()
+ OAuthClient.initialize(fakeWorkspace, fakeLogger as any, fakeLsp)
+ sinon.stub(OAuthClient as any, 'computeKey').returns('testkey')
+ stubHttpServer()
+ ;(fakeLsp.window.showDocument as sinon.SinonStub).resetHistory()
+ })
+
+ afterEach(() => sinon.restore())
+
+ it('returns cached token when still valid', async () => {
+ const cachedToken = {
+ access_token: 'cached_access',
+ expires_in: 3600,
+ obtained_at: now - 1_000,
+ }
+ const cachedReg = {
+ client_id: 'cid',
+ redirect_uri: 'http://localhost:12345',
+ }
+
+ stubFileSystem(cachedToken, cachedReg)
+
+ const token = await OAuthClient.getValidAccessToken(new URL('https://api.example.com/mcp'), {
+ interactive: true,
+ })
+ expect(token).to.equal('cached_access')
+ expect((fakeLsp.window.showDocument as sinon.SinonStub).called).to.be.false
+ })
+})
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.ts
new file mode 100644
index 0000000000..2e207c449e
--- /dev/null
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpOauthClient.ts
@@ -0,0 +1,484 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates.
+ * All Rights Reserved. SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { RequestInit } from 'node-fetch'
+import * as crypto from 'crypto'
+import * as path from 'path'
+import { spawn } from 'child_process'
+import { URL, URLSearchParams } from 'url'
+import * as http from 'http'
+import * as os from 'os'
+import { Logger, Workspace, Lsp } from '@aws/language-server-runtimes/server-interface'
+
+interface Token {
+ access_token: string
+ expires_in: number
+ refresh_token?: string
+ obtained_at: number
+}
+
+interface Meta {
+ authorization_endpoint: string
+ token_endpoint: string
+ registration_endpoint?: string
+}
+
+interface Registration {
+ client_id: string
+ client_secret?: string
+ expires_at?: number
+ redirect_uri: string
+}
+
+export class OAuthClient {
+ private static logger: Logger
+ private static workspace: Workspace
+ private static lsp: Lsp
+
+ public static initialize(ws: Workspace, logger: Logger, lsp: Lsp): void {
+ this.workspace = ws
+ this.logger = logger
+ this.lsp = lsp
+ }
+
+ /**
+ * Return a valid Bearer token, reusing cache or refresh-token if possible,
+ * otherwise (when interactive) driving one PKCE flow that may launch a browser.
+ */
+ public static async getValidAccessToken(
+ mcpBase: URL,
+ opts: { interactive?: boolean } = { interactive: false }
+ ): Promise {
+ const interactive = opts?.interactive === true
+ const key = this.computeKey(mcpBase)
+ const regPath = path.join(this.cacheDir, `${key}.registration.json`)
+ const tokPath = path.join(this.cacheDir, `${key}.token.json`)
+
+ // ===== Silent branch: try cached token, then refresh, never opens a browser =====
+ if (!interactive) {
+ // 1) cached access token
+ const cachedTok = await this.read(tokPath)
+ if (cachedTok) {
+ const expiry = cachedTok.obtained_at + cachedTok.expires_in * 1000
+ if (Date.now() < expiry) {
+ this.logger.info(`OAuth: using still-valid cached token (silent)`)
+ return cachedTok.access_token
+ }
+ this.logger.info(`OAuth: cached token expired → try refresh (silent)`)
+ }
+
+ // 2) refresh-token grant (if we have registration and refresh token)
+ const savedReg = await this.read(regPath)
+ if (cachedTok?.refresh_token && savedReg) {
+ try {
+ const meta = await this.discoverAS(mcpBase)
+ const refreshed = await this.refreshGrant(meta, savedReg, mcpBase, cachedTok.refresh_token)
+ if (refreshed) {
+ await this.write(tokPath, refreshed)
+ this.logger.info(`OAuth: refresh grant succeeded (silent)`)
+ return refreshed.access_token
+ }
+ this.logger.info(`OAuth: refresh grant did not succeed (silent)`)
+ } catch (e) {
+ this.logger.warn(`OAuth: silent refresh failed — ${e instanceof Error ? e.message : String(e)}`)
+ }
+ }
+
+ // 3) no token in silent mode → caller should surface auth-required UI
+ return undefined
+ }
+
+ // ===== Interactive branch: may open a browser (PKCE) =====
+ // 1) Spin up (or reuse) loopback server + redirect URI
+ let server: http.Server | null = null
+ let redirectUri: string
+ const savedReg = await this.read(regPath)
+ if (savedReg) {
+ const port = Number(new URL(savedReg.redirect_uri).port)
+ const normalized = `http://127.0.0.1:${port}`
+ server = http.createServer()
+ try {
+ await this.listen(server, port, '127.0.0.1')
+ redirectUri = normalized
+ this.logger.info(`OAuth: reusing redirect URI ${redirectUri}`)
+ } catch (e: any) {
+ if (e.code === 'EADDRINUSE') {
+ try {
+ server.close()
+ } catch {
+ /* ignore */
+ }
+ this.logger.warn(`Port ${port} in use; falling back to new random port`)
+ ;({ server, redirectUri } = await this.buildCallbackServer())
+ this.logger.info(`OAuth: new redirect URI ${redirectUri}`)
+ await this.workspace.fs.rm(regPath)
+ } else {
+ throw e
+ }
+ }
+ } else {
+ const created = await this.buildCallbackServer()
+ server = created.server
+ redirectUri = created.redirectUri
+ this.logger.info(`OAuth: new redirect URI ${redirectUri}`)
+ }
+
+ try {
+ // 2) Try still-valid cached access_token
+ const cached = await this.read(tokPath)
+ if (cached) {
+ const expiry = cached.obtained_at + cached.expires_in * 1000
+ if (Date.now() < expiry) {
+ this.logger.info(`OAuth: using still-valid cached token`)
+ return cached.access_token
+ }
+ this.logger.info(`OAuth: cached token expired → try refresh`)
+ }
+
+ // 3) Discover AS metadata
+ let meta: Meta
+ try {
+ meta = await this.discoverAS(mcpBase)
+ } catch (e: any) {
+ throw new Error(`OAuth discovery failed: ${e?.message ?? String(e)}`)
+ }
+
+ // 4) Register (or reuse) a dynamic client
+ const scopes = ['openid', 'offline_access']
+ let reg: Registration
+ try {
+ reg = await this.obtainClient(meta, regPath, scopes, redirectUri)
+ } catch (e: any) {
+ throw new Error(`OAuth client registration failed: ${e?.message ?? String(e)}`)
+ }
+
+ // 5) Refresh-token grant (one shot)
+ const attemptedRefresh = !!cached?.refresh_token
+ if (cached?.refresh_token) {
+ const refreshed = await this.refreshGrant(meta, reg, mcpBase, cached.refresh_token)
+ if (refreshed) {
+ await this.write(tokPath, refreshed)
+ this.logger.info(`OAuth: refresh grant succeeded`)
+ return refreshed.access_token
+ }
+ this.logger.info(`OAuth: refresh grant failed`)
+ }
+
+ // 6) PKCE interactive flow
+ try {
+ const fresh = await this.pkceGrant(meta, reg, mcpBase, scopes, redirectUri, server)
+ await this.write(tokPath, fresh)
+ return fresh.access_token
+ } catch (e: any) {
+ const suffix = attemptedRefresh ? ' after refresh attempt' : ''
+ throw new Error(`OAuth authorization (PKCE) failed${suffix}: ${e?.message ?? String(e)}`)
+ }
+ } finally {
+ if (server) {
+ await new Promise(res => server!.close(() => res()))
+ }
+ }
+ }
+
+ /** Spin up a one‑time HTTP listener on localhost:randomPort */
+ private static async buildCallbackServer(): Promise<{ server: http.Server; redirectUri: string }> {
+ const server = http.createServer()
+ await this.listen(server, 0, '127.0.0.1')
+ const port = (server.address() as any).port as number
+ return { server, redirectUri: `http://127.0.0.1:${port}` }
+ }
+
+ /** Discover OAuth endpoints by HEAD/WWW‑Authenticate, well‑known, or fallback */
+ private static async discoverAS(rs: URL): Promise {
+ // a) HEAD → WWW‑Authenticate → resource_metadata
+ try {
+ this.logger.info('MCP OAuth: attempting discovery via WWW-Authenticate header')
+ const h = await this.fetchCompat(rs.toString(), { method: 'HEAD' })
+ const header = h.headers.get('www-authenticate') || ''
+ const m = /resource_metadata=(?:"([^"]+)"|([^,\s]+))/i.exec(header)
+ if (m) {
+ const metaUrl = new URL(m[1] || m[2], rs).toString()
+ this.logger.info(`OAuth: resource_metadata → ${metaUrl}`)
+ const raw = await this.json(metaUrl)
+ return await this.fetchASFromResourceMeta(raw, metaUrl)
+ }
+ } catch {
+ this.logger.info('MCP OAuth: no resource_metadata found in WWW-Authenticate header')
+ }
+
+ // b) well‑known on resource host
+ this.logger.info('MCP OAuth: attempting discovery via well-known endpoints')
+ const probes = [
+ new URL('.well-known/oauth-authorization-server', rs).toString(),
+ new URL('.well-known/openid-configuration', rs).toString(),
+ `${rs.origin}/.well-known/oauth-authorization-server`,
+ `${rs.origin}/.well-known/openid-configuration`,
+ ]
+ for (const url of probes) {
+ try {
+ this.logger.info(`MCP OAuth: probing well-known endpoint → ${url}`)
+ return await this.json(url)
+ } catch (error) {
+ this.logger.info(`OAuth: well-known endpoint probe failed for ${url}`)
+ }
+ }
+
+ // c) fallback to static OAuth2 endpoints
+ const base = (rs.origin + rs.pathname).replace(/\/+$/, '')
+ this.logger.warn(`OAuth: all discovery attempts failed, synthesizing endpoints from ${base}`)
+ return {
+ authorization_endpoint: `${base}/authorize`,
+ token_endpoint: `${base}/access_token`,
+ }
+ }
+
+ /** Follow `authorization_server(s)` in resource_metadata JSON */
+ private static async fetchASFromResourceMeta(raw: any, metaUrl: string): Promise {
+ let asBase = raw.authorization_server
+ if (!asBase && Array.isArray(raw.authorization_servers)) {
+ asBase = raw.authorization_servers[0]
+ }
+ if (!asBase) {
+ throw new Error(`resource_metadata at ${metaUrl} lacked authorization_server(s)`)
+ }
+
+ // Attempt both OAuth‑AS and OIDC well‑known
+ for (const p of ['.well-known/oauth-authorization-server', '.well-known/openid-configuration']) {
+ try {
+ return await this.json(new URL(p, asBase).toString())
+ } catch {
+ // next
+ }
+ }
+ // fallback to static OAuth2 endpoints
+ this.logger.warn(`OAuth: no well-known on ${asBase}, falling back to static endpoints`)
+ return {
+ authorization_endpoint: `${asBase}/authorize`,
+ token_endpoint: `${asBase}/access_token`,
+ }
+ }
+
+ /** DCR: POST client metadata → client_id; cache to disk */
+ private static async obtainClient(
+ meta: Meta,
+ file: string,
+ scopes: string[],
+ redirectUri: string
+ ): Promise {
+ const existing = await this.read(file)
+ if (existing && (!existing.expires_at || existing.expires_at * 1000 > Date.now())) {
+ this.logger.info(`OAuth: reusing client_id ${existing.client_id}`)
+ return existing
+ }
+
+ if (!meta.registration_endpoint) {
+ throw new Error('OAuth: AS does not support dynamic registration')
+ }
+
+ const body = {
+ client_name: 'AWS MCP LSP',
+ grant_types: ['authorization_code', 'refresh_token'],
+ response_types: ['code'],
+ token_endpoint_auth_method: 'none',
+ scope: scopes.join(' '),
+ redirect_uris: [redirectUri],
+ }
+ const resp: any = await this.json(meta.registration_endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+
+ const reg: Registration = {
+ client_id: resp.client_id,
+ client_secret: resp.client_secret,
+ expires_at: resp.client_secret_expires_at,
+ redirect_uri: redirectUri,
+ }
+ await this.write(file, reg)
+ return reg
+ }
+
+ /** Try one refresh_token grant; returns new Token or `undefined` */
+ private static async refreshGrant(
+ meta: Meta,
+ reg: Registration,
+ rs: URL,
+ refresh: string
+ ): Promise {
+ const form = new URLSearchParams({
+ grant_type: 'refresh_token',
+ refresh_token: refresh,
+ client_id: reg.client_id,
+ resource: rs.toString(),
+ })
+ const res = await this.fetchCompat(meta.token_endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: form,
+ })
+ if (!res.ok) {
+ const msg = await res.text().catch(() => '')
+ this.logger.warn(`OAuth: refresh grant HTTP ${res.status} — ${msg?.slice(0, 300)}`)
+ return undefined
+ }
+ const tokenResponse = (await res.json()) as Record
+ return { ...(tokenResponse as object), obtained_at: Date.now() } as Token
+ }
+
+ /** One PKCE flow: browser + loopback → code → token */
+ private static async pkceGrant(
+ meta: Meta,
+ reg: Registration,
+ rs: URL,
+ scopes: string[],
+ redirectUri: string,
+ server: http.Server
+ ): Promise {
+ const DEFAULT_PKCE_TIMEOUT_MS = 90_000
+ // a) generate PKCE params
+ const verifier = this.b64url(crypto.randomBytes(32))
+ const challenge = this.b64url(crypto.createHash('sha256').update(verifier).digest())
+ const state = this.b64url(crypto.randomBytes(16))
+
+ // b) build authorize URL + launch browser
+ const authz = new URL(meta.authorization_endpoint)
+ authz.search = new URLSearchParams({
+ client_id: reg.client_id,
+ response_type: 'code',
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ resource: rs.toString(),
+ scope: scopes.join(' '),
+ redirect_uri: redirectUri,
+ state: state,
+ }).toString()
+
+ await this.lsp.window.showDocument({ uri: authz.toString(), external: true })
+
+ // c) wait for code on our loopback
+ const waitForFlow = new Promise<{ code: string; rxState: string; err?: string; errDesc?: string }>(resolve => {
+ server.on('request', (req, res) => {
+ const u = new URL(req.url || '/', redirectUri)
+ const c = u.searchParams.get('code') || ''
+ const s = u.searchParams.get('state') || ''
+ const e = u.searchParams.get('error') || undefined
+ const ed = u.searchParams.get('error_description') || undefined
+ res.writeHead(200, { 'content-type': 'text/html' }).end('You may close this tab.
')
+ resolve({ code: c, rxState: s, err: e, errDesc: ed })
+ })
+ })
+ const { code, rxState, err, errDesc } = await Promise.race([
+ waitForFlow,
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('authorization_timed_out')), DEFAULT_PKCE_TIMEOUT_MS)
+ ),
+ ])
+ if (err) {
+ throw new Error(`Authorization error: ${err}${errDesc ? ` - ${errDesc}` : ''}`)
+ }
+ if (!code || rxState !== state) throw new Error('Invalid authorization response (state mismatch)')
+
+ // d) exchange code for token
+ const form2 = new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ code_verifier: verifier,
+ client_id: reg.client_id,
+ redirect_uri: redirectUri,
+ resource: rs.toString(),
+ })
+ const res2 = await this.fetchCompat(meta.token_endpoint, {
+ method: 'POST',
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
+ body: form2,
+ })
+ if (!res2.ok) {
+ const txt = await res2.text().catch(() => '')
+ throw new Error(`Token exchange failed (HTTP ${res2.status}): ${txt?.slice(0, 300)}`)
+ }
+ const tk = (await res2.json()) as Record
+ return { ...(tk as object), obtained_at: Date.now() } as Token
+ }
+
+ /** Fetch + error‑check + parse JSON */
+ private static async json(url: string, init?: RequestInit): Promise {
+ const r = await this.fetchCompat(url, init)
+ if (!r.ok) {
+ const txt = await r.text().catch(() => '')
+ throw new Error(`HTTP ${r.status}@${url} — ${txt}`)
+ }
+ return (await r.json()) as T
+ }
+
+ /** Read & parse JSON file via workspace.fs */
+ private static async read(file: string): Promise {
+ try {
+ if (!(await this.workspace.fs.exists(file))) return undefined
+ const buf = await this.workspace.fs.readFile(file)
+ return JSON.parse(buf.toString()) as T
+ } catch {
+ return undefined
+ }
+ }
+
+ /** Write JSON, then clamp file perms to 0600 (owner read/write) */
+ private static async write(file: string, obj: unknown): Promise {
+ const dir = path.dirname(file)
+ await this.workspace.fs.mkdir(dir, { recursive: true })
+ await this.workspace.fs.writeFile(file, JSON.stringify(obj, null, 2), { mode: 0o600 })
+ }
+
+ /** SHA‑256 of resourceServer URL → hex key */
+ private static computeKey(rs: URL): string {
+ return crypto
+ .createHash('sha256')
+ .update(rs.origin + rs.pathname)
+ .digest('hex')
+ }
+
+ /** RFC‑7636 base64url without padding */
+ private static b64url(buf: Buffer): string {
+ return buf.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
+ }
+
+ /** Directory for caching registration + tokens */
+ private static readonly cacheDir = path.join(os.homedir(), '.aws', 'sso', 'cache')
+
+ /**
+ * Await server.listen() but reject if it emits 'error' (eg EADDRINUSE),
+ * so callers can handle it immediately instead of hanging.
+ */
+ private static listen(server: http.Server, port: number, host: string = '127.0.0.1'): Promise {
+ return new Promise((resolve, reject) => {
+ const onListening = () => {
+ server.off('error', onError)
+ resolve()
+ }
+ const onError = (err: NodeJS.ErrnoException) => {
+ server.off('listening', onListening)
+ reject(err)
+ }
+ server.once('listening', onListening)
+ server.once('error', onError)
+ server.listen(port, host)
+ })
+ }
+
+ /**
+ * Fetch compatibility: use global fetch on Node >= 18, otherwise dynamically import('node-fetch').
+ * Using Function('return import(...)') avoids downleveling to require() in CJS builds.
+ */
+ private static async fetchCompat(url: string, init?: RequestInit): Promise {
+ const globalObj = globalThis as any
+ if (typeof globalObj.fetch === 'function') {
+ return globalObj.fetch(url as any, init as any)
+ }
+ // Dynamic import of ESM node-fetch (only when global fetch is unavailable)
+ const mod = await (Function('return import("node-fetch")')() as Promise)
+ const f = mod.default ?? mod
+ return f(url as any, init as any)
+ }
+}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts
index 6aec98cb24..feed97dba3 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
@@ -46,19 +46,26 @@ export interface MCPServerPermission {
export interface AgentConfig {
name: string // Required: Agent name
- version: string // Required: Agent version (semver)
description: string // Required: Agent description
+ prompt?: string // Optional: High-level context for the agent
model?: string // Optional: Model that backs the agent
tags?: string[] // Optional: Tags for categorization
inputSchema?: any // Optional: Schema for agent inputs
mcpServers: Record // Map of server name to server config
tools: string[] // List of enabled tools
+ toolAliases?: Record // Tool name remapping
allowedTools: string[] // List of tools that don't require approval
toolsSettings?: Record // Tool-specific settings
- includedFiles?: string[] // Files to include in context
- createHooks?: string[] // Hooks to run at conversation start
- promptHooks?: string[] // Hooks to run per prompt
- resources?: string[] // Resources for the agent (prompts, files, etc.)
+ resources?: string[] // Resources for the agent (file:// paths)
+ hooks?: {
+ agentSpawn?: Array<{ command: string }>
+ userPromptSubmit?: Array<{ command: string }>
+ } // Commands run at specific trigger points
+ useLegacyMcpJson?: boolean // Whether to include legacy MCP configuration
+ // Legacy fields for backward compatibility
+ includedFiles?: string[] // Deprecated: use resources instead
+ createHooks?: string[] // Deprecated: use hooks.agentSpawn instead
+ promptHooks?: string[] // Deprecated: use hooks.userPromptSubmit instead
}
export interface PersonaConfig {
@@ -71,20 +78,24 @@ export class AgentModel {
static fromJson(doc: any): AgentModel {
const cfg: AgentConfig = {
- name: doc?.['name'] || 'default-agent',
- version: doc?.['version'] || '1.0.0',
+ name: doc?.['name'] || 'amazon_q_default',
description: doc?.['description'] || 'Default agent configuration',
+ prompt: doc?.['prompt'],
model: doc?.['model'],
tags: Array.isArray(doc?.['tags']) ? doc['tags'] : undefined,
inputSchema: doc?.['inputSchema'],
mcpServers: typeof doc?.['mcpServers'] === 'object' ? doc['mcpServers'] : {},
tools: Array.isArray(doc?.['tools']) ? doc['tools'] : [],
+ toolAliases: typeof doc?.['toolAliases'] === 'object' ? doc['toolAliases'] : {},
allowedTools: Array.isArray(doc?.['allowedTools']) ? doc['allowedTools'] : [],
toolsSettings: typeof doc?.['toolsSettings'] === 'object' ? doc['toolsSettings'] : {},
+ resources: Array.isArray(doc?.['resources']) ? doc['resources'] : [],
+ hooks: typeof doc?.['hooks'] === 'object' ? doc['hooks'] : undefined,
+ useLegacyMcpJson: doc?.['useLegacyMcpJson'],
+ // Legacy fields
includedFiles: Array.isArray(doc?.['includedFiles']) ? doc['includedFiles'] : [],
createHooks: Array.isArray(doc?.['createHooks']) ? doc['createHooks'] : [],
promptHooks: Array.isArray(doc?.['promptHooks']) ? doc['promptHooks'] : [],
- resources: Array.isArray(doc?.['resources']) ? doc['resources'] : [],
}
return new AgentModel(cfg)
}
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 c50f1d62eb..8913e7391e 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
@@ -22,6 +22,7 @@ import {
enabledMCP,
normalizePathFromUri,
saveAgentConfig,
+ saveServerSpecificAgentConfig,
isEmptyEnv,
sanitizeName,
convertPersonaToAgent,
@@ -237,7 +238,6 @@ describe('loadAgentConfig', () => {
const agentConfig = {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {
testServer: {
@@ -328,7 +328,6 @@ describe('saveAgentConfig', () => {
const configPath = path.join(tmpDir, 'agent-config.json')
const config = {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: ['tool1', 'tool2'],
@@ -352,7 +351,6 @@ describe('saveAgentConfig', () => {
const configPath = path.join(tmpDir, 'nested', 'dir', 'agent-config.json')
const config = {
name: 'test-agent',
- version: '1.0.0',
description: 'Test agent',
mcpServers: {},
tools: [],
@@ -699,7 +697,7 @@ describe('convertPersonaToAgent', () => {
const result = convertPersonaToAgent(persona, mcpServers, mockAgent)
- expect(result.name).to.equal('default-agent')
+ expect(result.name).to.equal('amazon_q_default')
expect(result.mcpServers).to.have.property('testServer')
expect(result.tools).to.include('@testServer')
expect(result.tools).to.include('fs_read')
@@ -753,6 +751,12 @@ describe('migrateToAgentConfig', () => {
})
it('migrates when no existing configs exist', async () => {
+ // Create empty MCP config to trigger migration
+ const mcpDir = path.join(tmpDir, '.aws', 'amazonq')
+ fs.mkdirSync(mcpDir, { recursive: true })
+ const mcpPath = path.join(mcpDir, 'mcp.json')
+ fs.writeFileSync(mcpPath, JSON.stringify({ mcpServers: {} }))
+
await migrateToAgentConfig(workspace, logger, mockAgent)
// Should create default agent config
@@ -782,3 +786,147 @@ describe('migrateToAgentConfig', () => {
expect(agentConfig.mcpServers).to.have.property('testServer')
})
})
+describe('saveServerSpecificAgentConfig', () => {
+ let tmpDir: string
+ let workspace: any
+ let logger: any
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'saveServerSpecificTest-'))
+ workspace = {
+ fs: {
+ exists: (p: string) => Promise.resolve(fs.existsSync(p)),
+ readFile: (p: string) => Promise.resolve(Buffer.from(fs.readFileSync(p))),
+ writeFile: (p: string, d: string) => Promise.resolve(fs.writeFileSync(p, d)),
+ mkdir: (d: string, opts: any) => Promise.resolve(fs.mkdirSync(d, { recursive: opts.recursive })),
+ },
+ }
+ logger = { warn: () => {}, info: () => {}, error: () => {} }
+ })
+
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true })
+ })
+
+ it('creates new config file when it does not exist', async () => {
+ const configPath = path.join(tmpDir, 'agent-config.json')
+ const serverConfig = { command: 'test-cmd', args: ['arg1'] }
+ const serverTools = ['@testServer']
+ const serverAllowedTools = ['@testServer/tool1']
+
+ await saveServerSpecificAgentConfig(
+ workspace,
+ logger,
+ 'testServer',
+ serverConfig,
+ serverTools,
+ serverAllowedTools,
+ configPath
+ )
+
+ expect(fs.existsSync(configPath)).to.be.true
+ const content = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
+ expect(content.mcpServers.testServer).to.deep.equal(serverConfig)
+ expect(content.tools).to.include('@testServer')
+ expect(content.allowedTools).to.include('@testServer/tool1')
+ })
+
+ it('updates existing config file', async () => {
+ const configPath = path.join(tmpDir, 'agent-config.json')
+
+ // Create existing config
+ const existingConfig = {
+ name: 'existing-agent',
+ description: 'Existing agent',
+ mcpServers: {
+ existingServer: { command: 'existing-cmd' },
+ },
+ tools: ['fs_read', '@existingServer'],
+ allowedTools: ['fs_read'],
+ toolsSettings: {},
+ includedFiles: [],
+ resources: [],
+ }
+ fs.writeFileSync(configPath, JSON.stringify(existingConfig))
+
+ const serverConfig = { command: 'new-cmd', args: ['arg1'] }
+ const serverTools = ['@newServer']
+ const serverAllowedTools = ['@newServer/tool1']
+
+ await saveServerSpecificAgentConfig(
+ workspace,
+ logger,
+ 'newServer',
+ serverConfig,
+ serverTools,
+ serverAllowedTools,
+ configPath
+ )
+
+ const content = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
+ expect(content.name).to.equal('existing-agent')
+ expect(content.mcpServers.existingServer).to.deep.equal({ command: 'existing-cmd' })
+ expect(content.mcpServers.newServer).to.deep.equal(serverConfig)
+ expect(content.tools).to.include('@newServer')
+ expect(content.allowedTools).to.include('@newServer/tool1')
+ })
+
+ it('removes existing server tools before adding new ones', async () => {
+ const configPath = path.join(tmpDir, 'agent-config.json')
+
+ // Create existing config with server tools
+ const existingConfig = {
+ name: 'test-agent',
+ description: 'Test agent',
+ mcpServers: {
+ testServer: { command: 'old-cmd' },
+ },
+ tools: ['fs_read', '@testServer', '@testServer/oldTool'],
+ allowedTools: ['fs_read', '@testServer/oldAllowedTool'],
+ toolsSettings: {},
+ includedFiles: [],
+ resources: [],
+ }
+ fs.writeFileSync(configPath, JSON.stringify(existingConfig))
+
+ const serverConfig = { command: 'new-cmd' }
+ const serverTools = ['@testServer']
+ const serverAllowedTools = ['@testServer/newTool']
+
+ await saveServerSpecificAgentConfig(
+ workspace,
+ logger,
+ 'testServer',
+ serverConfig,
+ serverTools,
+ serverAllowedTools,
+ configPath
+ )
+
+ const content = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
+ expect(content.tools).to.not.include('@testServer/oldTool')
+ expect(content.allowedTools).to.not.include('@testServer/oldAllowedTool')
+ expect(content.tools).to.include('@testServer')
+ expect(content.allowedTools).to.include('@testServer/newTool')
+ expect(content.tools).to.include('fs_read')
+ })
+
+ it('creates parent directories if they do not exist', async () => {
+ const configPath = path.join(tmpDir, 'nested', 'dir', 'agent-config.json')
+ const serverConfig = { command: 'test-cmd' }
+ const serverTools = ['@testServer']
+ const serverAllowedTools: string[] = []
+
+ await saveServerSpecificAgentConfig(
+ workspace,
+ logger,
+ 'testServer',
+ serverConfig,
+ serverTools,
+ serverAllowedTools,
+ configPath
+ )
+
+ expect(fs.existsSync(configPath)).to.be.true
+ })
+})
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts
index 7b212bad49..b8360ea314 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
@@ -9,7 +9,6 @@ import { MCPServerConfig, PersonaConfig, MCPServerPermission, McpPermissionType,
import path = require('path')
import { QClientCapabilities } from '../../../configuration/qConfigurationServer'
import crypto = require('crypto')
-import { Features } from '@aws/language-server-runtimes/server-interface/server'
/**
* Load, validate, and parse MCP server configurations from JSON files.
@@ -148,9 +147,9 @@ export async function loadMcpServerConfigs(
}
const DEFAULT_AGENT_RAW = `{
- "name": "default-agent",
- "version": "1.0.0",
+ "name": "amazon_q_default",
"description": "Default agent configuration",
+ "prompt": "",
"mcpServers": {},
"tools": [
"fs_read",
@@ -159,6 +158,7 @@ const DEFAULT_AGENT_RAW = `{
"report_issue",
"use_aws"
],
+ "toolAliases": {},
"allowedTools": [
"fs_read",
"report_issue",
@@ -170,12 +170,16 @@ const DEFAULT_AGENT_RAW = `{
"use_aws": { "preset": "readOnly" },
"execute_bash": { "preset": "readOnly" }
},
- "includedFiles": [
- "AmazonQ.md",
- "README.md",
- ".amazonq/rules/**/*.md"
+ "resources": [
+ "file://AmazonQ.md",
+ "file://README.md",
+ "file://.amazonq/rules/**/*.md"
],
- "resources": []
+ "hooks": {
+ "agentSpawn": [],
+ "userPromptSubmit": []
+ },
+ "useLegacyMcpJson": false
}`
const DEFAULT_PERSONA_RAW = `{
@@ -236,14 +240,12 @@ export async function loadAgentConfig(
// Create base agent config
const agentConfig: AgentConfig = {
- name: 'default-agent',
- version: '1.0.0',
+ name: 'amazon_q_default',
description: 'Agent configuration',
mcpServers: {},
tools: [],
allowedTools: [],
toolsSettings: {},
- includedFiles: [],
resources: [],
}
@@ -328,7 +330,6 @@ export async function loadAgentConfig(
// 3) Process agent config metadata
if (fsPath === globalConfigPath) {
agentConfig.name = json.name || agentConfig.name
- agentConfig.version = json.version || agentConfig.version
agentConfig.description = json.description || agentConfig.description
}
@@ -630,15 +631,20 @@ export function convertPersonaToAgent(
featureAgent: Agent
): AgentConfig {
const agent: AgentConfig = {
- name: 'default-agent',
- version: '1.0.0',
+ name: 'amazon_q_default',
description: 'Default agent configuration',
+ prompt: '',
mcpServers: {},
tools: [],
+ toolAliases: {},
allowedTools: [],
toolsSettings: {},
- includedFiles: [],
resources: [],
+ hooks: {
+ agentSpawn: [],
+ userPromptSubmit: [],
+ },
+ useLegacyMcpJson: false,
}
// Include all servers from MCP config
@@ -840,48 +846,40 @@ async function migrateConfigToAgent(
const normalizedPersonaPath = normalizePathFromUri(personaPath, logging)
agentPath = normalizePathFromUri(agentPath)
- // Check if agent config exists
+ // Check if config and agent files exist
+ const configExists = await workspace.fs.exists(normalizedConfigPath).catch(() => false)
const agentExists = await workspace.fs.exists(agentPath).catch(() => false)
- // Load existing agent config if it exists
- let existingAgentConfig: AgentConfig | undefined
+ // Only migrate if agent file does not exist
+ // If config exists, migrate from it; if not, create default agent config
if (agentExists) {
- try {
- const raw = (await workspace.fs.readFile(agentPath)).toString().trim()
- existingAgentConfig = raw ? JSON.parse(raw) : undefined
- } catch (err) {
- logging.warn(`Failed to read existing agent config at ${agentPath}: ${err}`)
- }
+ return
}
// Read MCP server configs directly from file
const serverConfigs: Record = {}
try {
- const configExists = await workspace.fs.exists(normalizedConfigPath)
-
- if (configExists) {
- const raw = (await workspace.fs.readFile(normalizedConfigPath)).toString().trim()
- if (raw) {
- const config = JSON.parse(raw)
-
- if (config.mcpServers && typeof config.mcpServers === 'object') {
- // Add each server to the serverConfigs
- for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
- serverConfigs[name] = {
- command: (serverConfig as any).command,
- args: Array.isArray((serverConfig as any).args) ? (serverConfig as any).args : undefined,
- env: typeof (serverConfig as any).env === 'object' ? (serverConfig as any).env : undefined,
- initializationTimeout:
- typeof (serverConfig as any).initializationTimeout === 'number'
- ? (serverConfig as any).initializationTimeout
- : undefined,
- timeout:
- typeof (serverConfig as any).timeout === 'number'
- ? (serverConfig as any).timeout
- : undefined,
- }
- logging.info(`Added server ${name} to serverConfigs`)
+ const raw = (await workspace.fs.readFile(normalizedConfigPath)).toString().trim()
+ if (raw) {
+ const config = JSON.parse(raw)
+
+ if (config.mcpServers && typeof config.mcpServers === 'object') {
+ // Add each server to the serverConfigs
+ for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
+ serverConfigs[name] = {
+ command: (serverConfig as any).command,
+ args: Array.isArray((serverConfig as any).args) ? (serverConfig as any).args : undefined,
+ env: typeof (serverConfig as any).env === 'object' ? (serverConfig as any).env : undefined,
+ initializationTimeout:
+ typeof (serverConfig as any).initializationTimeout === 'number'
+ ? (serverConfig as any).initializationTimeout
+ : undefined,
+ timeout:
+ typeof (serverConfig as any).timeout === 'number'
+ ? (serverConfig as any).timeout
+ : undefined,
}
+ logging.info(`Added server ${name} to serverConfigs`)
}
}
}
@@ -908,46 +906,23 @@ async function migrateConfigToAgent(
}
// Convert to agent config
- const newAgentConfig = convertPersonaToAgent(personaConfig, serverConfigs, agent)
- newAgentConfig.includedFiles = ['AmazonQ.md', 'README.md', '.amazonq/rules/**/*.md']
- newAgentConfig.resources = [] // Initialize with empty array
-
- // Merge with existing config if available
- let finalAgentConfig: AgentConfig
- if (existingAgentConfig) {
- // Keep existing metadata
- finalAgentConfig = {
- ...existingAgentConfig,
- // Merge MCP servers, keeping existing ones if they exist
- mcpServers: {
- ...newAgentConfig.mcpServers,
- ...existingAgentConfig.mcpServers,
- },
- // Merge tools lists without duplicates
- tools: [...new Set([...existingAgentConfig.tools, ...newAgentConfig.tools])],
- allowedTools: [...new Set([...existingAgentConfig.allowedTools, ...newAgentConfig.allowedTools])],
- // Merge tool settings, preferring existing ones
- toolsSettings: {
- ...newAgentConfig.toolsSettings,
- ...existingAgentConfig.toolsSettings,
- },
- // Keep other properties from existing config
- includedFiles: existingAgentConfig.includedFiles || newAgentConfig.includedFiles,
- createHooks: existingAgentConfig.createHooks || newAgentConfig.createHooks,
- promptHooks: [
- ...new Set([...(existingAgentConfig.promptHooks || []), ...(newAgentConfig.promptHooks || [])]),
- ],
- resources: [...new Set([...(existingAgentConfig.resources || []), ...(newAgentConfig.resources || [])])],
- }
- } else {
- finalAgentConfig = newAgentConfig
- logging.info(`Using new config (no existing config to merge)`)
- }
+ const agentConfig = convertPersonaToAgent(personaConfig, serverConfigs, agent)
+
+ // Parse default values from DEFAULT_AGENT_RAW
+ const defaultAgent = JSON.parse(DEFAULT_AGENT_RAW)
+
+ // Add complete agent format sections using default values
+ agentConfig.name = defaultAgent.name
+ agentConfig.description = defaultAgent.description
+ agentConfig.includedFiles = defaultAgent.includedFiles
+ agentConfig.resources = defaultAgent.resources
+ agentConfig.createHooks = defaultAgent.createHooks
+ agentConfig.promptHooks = defaultAgent.promptHooks
// Save agent config
try {
- await saveAgentConfig(workspace, logging, finalAgentConfig, agentPath)
- logging.info(`Successfully ${existingAgentConfig ? 'updated' : 'created'} agent config at ${agentPath}`)
+ await saveAgentConfig(workspace, logging, agentConfig, agentPath)
+ logging.info(`Successfully created agent config at ${agentPath}`)
} catch (err) {
logging.error(`Failed to save agent config to ${agentPath}: ${err}`)
throw err
@@ -958,67 +933,164 @@ export async function saveAgentConfig(
workspace: Workspace,
logging: Logger,
config: AgentConfig,
- configPath: string,
- serverName?: string
+ configPath: string
): Promise {
try {
await workspace.fs.mkdir(path.dirname(configPath), { recursive: true })
+ // Save the whole config
+ await workspace.fs.writeFile(configPath, JSON.stringify(config, null, 2))
+ logging.info(`Saved agent config to ${configPath}`)
+ } catch (err: any) {
+ logging.error(`Failed to save agent config to ${configPath}: ${err.message}`)
+ throw err
+ }
+}
- if (!serverName) {
- // Save the whole config
- await workspace.fs.writeFile(configPath, JSON.stringify(config, null, 2))
- logging.info(`Saved agent config to ${configPath}`)
- return
- }
+/**
+ * Save only server-specific changes to agent config file
+ */
+export async function saveServerSpecificAgentConfig(
+ workspace: Workspace,
+ logging: Logger,
+ serverName: string,
+ serverConfig: any,
+ serverTools: string[],
+ serverAllowedTools: string[],
+ configPath: string
+): Promise {
+ try {
+ await workspace.fs.mkdir(path.dirname(configPath), { recursive: true })
- // Read existing config if it exists, otherwise use default
- let existingConfig: any
+ // Read existing config
+ let existingConfig: AgentConfig
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)
+ const raw = await workspace.fs.readFile(configPath)
+ existingConfig = JSON.parse(raw.toString())
+ } catch {
+ // If file doesn't exist, create minimal config
+ existingConfig = {
+ name: 'amazon_q_default',
+ description: 'Agent configuration',
+ mcpServers: {},
+ tools: [],
+ allowedTools: [],
+ toolsSettings: {},
+ includedFiles: [],
+ resources: [],
}
- } 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}`
+ // Remove existing server tools from arrays
+ const serverPrefix = `@${serverName}`
existingConfig.tools = existingConfig.tools.filter(
- (tool: string) => tool !== serverToolPattern && !tool.startsWith(`${serverToolPattern}/`)
+ tool => tool !== serverPrefix && !tool.startsWith(`${serverPrefix}/`)
)
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}/`)
+ tool => tool !== serverPrefix && !tool.startsWith(`${serverPrefix}/`)
)
- existingConfig.tools.push(...serverTools)
- existingConfig.allowedTools.push(...serverAllowedTools)
+ if (serverConfig === null) {
+ // Remove server entirely
+ delete existingConfig.mcpServers[serverName]
+ } else {
+ // Update or add server
+ existingConfig.mcpServers[serverName] = serverConfig
+ // Add new server tools
+ existingConfig.tools.push(...serverTools)
+ existingConfig.allowedTools.push(...serverAllowedTools)
+ }
await workspace.fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2))
- logging.info(`Saved agent config for server ${serverName} to ${configPath}`)
+ logging.info(`Saved server-specific agent config for ${serverName} to ${configPath}`)
} catch (err: any) {
- logging.error(`Failed to save agent config to ${configPath}: ${err.message}`)
+ logging.error(`Failed to save server-specific agent config to ${configPath}: ${err.message}`)
throw err
}
}
+/**
+ * Migrate existing agent config to CLI format
+ */
+export async function migrateAgentConfigToCLIFormat(
+ workspace: Workspace,
+ logging: Logger,
+ configPath: string
+): Promise {
+ try {
+ const exists = await workspace.fs.exists(configPath)
+ if (!exists) return
+
+ const raw = await workspace.fs.readFile(configPath)
+ const config = JSON.parse(raw.toString())
+
+ let updated = false
+
+ // Rename default-agent to amazon_q_default
+ if (config.name === 'default-agent') {
+ config.name = 'amazon_q_default'
+ updated = true
+ }
+
+ // Add missing CLI fields
+ if (!config.hasOwnProperty('prompt')) {
+ config.prompt = ''
+ updated = true
+ }
+ if (!config.hasOwnProperty('toolAliases')) {
+ config.toolAliases = {}
+ updated = true
+ }
+ if (!config.hasOwnProperty('useLegacyMcpJson')) {
+ config.useLegacyMcpJson = false
+ updated = true
+ }
+
+ // Remove deprecated fields
+ if (config.hasOwnProperty('version')) {
+ delete config.version
+ updated = true
+ }
+
+ // Migrate includedFiles to resources with file:// prefix
+ if (config.includedFiles && Array.isArray(config.includedFiles)) {
+ if (!config.resources) config.resources = []
+ for (const file of config.includedFiles) {
+ const resourcePath = file.startsWith('file://') ? file : `file://${file}`
+ if (!config.resources.includes(resourcePath)) {
+ config.resources.push(resourcePath)
+ }
+ }
+ delete config.includedFiles
+ updated = true
+ }
+
+ // Migrate hooks format
+ if (config.promptHooks || config.createHooks) {
+ if (!config.hooks) config.hooks = {}
+ if (!config.hooks.agentSpawn) config.hooks.agentSpawn = []
+ if (!config.hooks.userPromptSubmit) config.hooks.userPromptSubmit = []
+
+ if (config.createHooks && Array.isArray(config.createHooks)) {
+ config.hooks.agentSpawn.push(...config.createHooks)
+ delete config.createHooks
+ updated = true
+ }
+ if (config.promptHooks && Array.isArray(config.promptHooks)) {
+ config.hooks.userPromptSubmit.push(...config.promptHooks)
+ delete config.promptHooks
+ updated = true
+ }
+ }
+
+ if (updated) {
+ await workspace.fs.writeFile(configPath, JSON.stringify(config, null, 2))
+ logging.info(`Migrated agent config to CLI format: ${configPath}`)
+ }
+ } catch (err: any) {
+ logging.error(`Failed to migrate agent config ${configPath}: ${err.message}`)
+ }
+}
+
export const MAX_TOOL_NAME_LENGTH = 64
/**
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.test.ts
index 8ee8454374..6fb0e56f9a 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.test.ts
@@ -3,17 +3,24 @@
* All Rights Reserved. SPDX-License-Identifier: Apache-2.0
*/
-import { expect } from 'chai'
+import * as chai from 'chai'
import * as sinon from 'sinon'
import { ProfileStatusMonitor } from './profileStatusMonitor'
import * as AmazonQTokenServiceManagerModule from '../../../../shared/amazonQServiceManager/AmazonQTokenServiceManager'
+const { expect } = chai
+
+interface MockLogging {
+ info: sinon.SinonStub
+ debug: sinon.SinonStub
+ error: sinon.SinonStub
+ warn: sinon.SinonStub
+ log: sinon.SinonStub
+}
+
describe('ProfileStatusMonitor', () => {
let profileStatusMonitor: ProfileStatusMonitor
- let mockCredentialsProvider: any
- let mockWorkspace: any
- let mockLogging: any
- let mockSdkInitializator: any
+ let mockLogging: MockLogging
let mockOnMcpDisabled: sinon.SinonStub
let mockOnMcpEnabled: sinon.SinonStub
let clock: sinon.SinonFakeTimers
@@ -21,29 +28,18 @@ describe('ProfileStatusMonitor', () => {
beforeEach(() => {
clock = sinon.useFakeTimers()
- mockCredentialsProvider = {
- hasCredentials: sinon.stub().returns(true),
- }
-
- mockWorkspace = {}
-
mockLogging = {
info: sinon.stub(),
debug: sinon.stub(),
+ error: sinon.stub(),
+ warn: sinon.stub(),
+ log: sinon.stub(),
}
- mockSdkInitializator = {}
mockOnMcpDisabled = sinon.stub()
mockOnMcpEnabled = sinon.stub()
- profileStatusMonitor = new ProfileStatusMonitor(
- mockCredentialsProvider,
- mockWorkspace,
- mockLogging,
- mockSdkInitializator,
- mockOnMcpDisabled,
- mockOnMcpEnabled
- )
+ profileStatusMonitor = new ProfileStatusMonitor(mockLogging, mockOnMcpDisabled, mockOnMcpEnabled)
})
afterEach(() => {
@@ -118,23 +114,9 @@ describe('ProfileStatusMonitor', () => {
})
it('should be accessible across different instances', () => {
- const monitor1 = new ProfileStatusMonitor(
- mockCredentialsProvider,
- mockWorkspace,
- mockLogging,
- mockSdkInitializator,
- mockOnMcpDisabled,
- mockOnMcpEnabled
- )
-
- const monitor2 = new ProfileStatusMonitor(
- mockCredentialsProvider,
- mockWorkspace,
- mockLogging,
- mockSdkInitializator,
- mockOnMcpDisabled,
- mockOnMcpEnabled
- )
+ const monitor1 = new ProfileStatusMonitor(mockLogging, mockOnMcpDisabled, mockOnMcpEnabled)
+
+ const monitor2 = new ProfileStatusMonitor(mockLogging, mockOnMcpDisabled, mockOnMcpEnabled)
// Set state through static property
;(ProfileStatusMonitor as any).lastMcpState = true
@@ -151,26 +133,12 @@ describe('ProfileStatusMonitor', () => {
})
it('should maintain state across multiple instances', () => {
- const monitor1 = new ProfileStatusMonitor(
- mockCredentialsProvider,
- mockWorkspace,
- mockLogging,
- mockSdkInitializator,
- mockOnMcpDisabled,
- mockOnMcpEnabled
- )
-
- const monitor2 = new ProfileStatusMonitor(
- mockCredentialsProvider,
- mockWorkspace,
- mockLogging,
- mockSdkInitializator,
- mockOnMcpDisabled,
- mockOnMcpEnabled
- )
-
- // Initially undefined
- expect(ProfileStatusMonitor.getMcpState()).to.be.undefined
+ const monitor1 = new ProfileStatusMonitor(mockLogging, mockOnMcpDisabled, mockOnMcpEnabled)
+
+ const monitor2 = new ProfileStatusMonitor(mockLogging, mockOnMcpDisabled, mockOnMcpEnabled)
+
+ // Initially true (default value)
+ expect(ProfileStatusMonitor.getMcpState()).to.be.true
// Set through internal mechanism (simulating state change)
;(ProfileStatusMonitor as any).lastMcpState = false
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.ts
index 4a1cc705e4..3489ad81be 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/profileStatusMonitor.ts
@@ -3,32 +3,40 @@
* All Rights Reserved. SPDX-License-Identifier: Apache-2.0
*/
-import {
- CredentialsProvider,
- Logging,
- SDKInitializator,
- Workspace,
-} from '@aws/language-server-runtimes/server-interface'
+import { Logging } from '@aws/language-server-runtimes/server-interface'
import { retryUtils } from '@aws/lsp-core'
import { CodeWhispererServiceToken } from '../../../../shared/codeWhispererService'
-import { DEFAULT_AWS_Q_ENDPOINT_URL, DEFAULT_AWS_Q_REGION } from '../../../../shared/constants'
import { AmazonQTokenServiceManager } from '../../../../shared/amazonQServiceManager/AmazonQTokenServiceManager'
+import * as fs from 'fs'
+import * as path from 'path'
+import * as os from 'os'
+import { EventEmitter } from 'events'
+
+export const AUTH_SUCCESS_EVENT = 'authSuccess'
export class ProfileStatusMonitor {
private intervalId?: NodeJS.Timeout
private readonly CHECK_INTERVAL = 24 * 60 * 60 * 1000 // 24 hours
private codeWhispererClient?: CodeWhispererServiceToken
- private cachedProfileArn?: string
- private static lastMcpState?: boolean
+ private static lastMcpState: boolean = true
+ private static readonly MCP_CACHE_DIR = path.join(os.homedir(), '.aws', 'amazonq', 'mcpAdmin')
+ private static readonly MCP_CACHE_FILE = path.join(ProfileStatusMonitor.MCP_CACHE_DIR, 'mcp-state.json')
+ private static eventEmitter = new EventEmitter()
+ private static logging?: Logging
constructor(
- private credentialsProvider: CredentialsProvider,
- private workspace: Workspace,
private logging: Logging,
- private sdkInitializator: SDKInitializator,
private onMcpDisabled: () => void,
private onMcpEnabled?: () => void
- ) {}
+ ) {
+ ProfileStatusMonitor.logging = logging
+ ProfileStatusMonitor.loadMcpStateFromDisk()
+
+ // Listen for auth success events
+ ProfileStatusMonitor.eventEmitter.on(AUTH_SUCCESS_EVENT, () => {
+ void this.isMcpEnabled()
+ })
+ }
async checkInitialState(): Promise {
try {
@@ -36,8 +44,7 @@ export class ProfileStatusMonitor {
return isMcpEnabled !== false // Return true if enabled or API failed
} catch (error) {
this.logging.debug(`Initial MCP state check failed, defaulting to enabled: ${error}`)
- ProfileStatusMonitor.lastMcpState = true
- return true
+ return ProfileStatusMonitor.getMcpState()
}
}
@@ -63,32 +70,24 @@ export class ProfileStatusMonitor {
private async isMcpEnabled(): Promise {
try {
- const profileArn = this.getProfileArn()
+ const serviceManager = AmazonQTokenServiceManager.getInstance()
+ const profileArn = this.getProfileArn(serviceManager)
if (!profileArn) {
this.logging.debug('No profile ARN available for MCP configuration check')
- ProfileStatusMonitor.lastMcpState = true // Default to enabled if no profile
+ ProfileStatusMonitor.setMcpState(true)
return true
}
- if (!this.codeWhispererClient) {
- this.codeWhispererClient = new CodeWhispererServiceToken(
- this.credentialsProvider,
- this.workspace,
- this.logging,
- process.env.CODEWHISPERER_REGION || DEFAULT_AWS_Q_REGION,
- process.env.CODEWHISPERER_ENDPOINT || DEFAULT_AWS_Q_ENDPOINT_URL,
- this.sdkInitializator
- )
- this.codeWhispererClient.profileArn = profileArn
- }
+ this.codeWhispererClient = serviceManager.getCodewhispererService()
const response = await retryUtils.retryWithBackoff(() =>
this.codeWhispererClient!.getProfile({ profileArn })
)
- const isMcpEnabled = response?.profile?.optInFeatures?.mcpConfiguration?.toggle === 'ON'
+ const mcpConfig = response?.profile?.optInFeatures?.mcpConfiguration
+ const isMcpEnabled = mcpConfig ? mcpConfig.toggle === 'ON' : true
if (ProfileStatusMonitor.lastMcpState !== isMcpEnabled) {
- ProfileStatusMonitor.lastMcpState = isMcpEnabled
+ ProfileStatusMonitor.setMcpState(isMcpEnabled)
if (!isMcpEnabled) {
this.logging.info('MCP configuration disabled - removing tools')
this.onMcpDisabled()
@@ -101,29 +100,64 @@ export class ProfileStatusMonitor {
return isMcpEnabled
} catch (error) {
this.logging.debug(`MCP configuration check failed, defaulting to enabled: ${error}`)
- ProfileStatusMonitor.lastMcpState = true
- return true
+ const mcpState = ProfileStatusMonitor.getMcpState()
+ if (!mcpState) {
+ this.onMcpDisabled()
+ } else if (this.onMcpEnabled) {
+ this.onMcpEnabled()
+ }
+ return mcpState
}
}
- private getProfileArn(): string | undefined {
- // Use cached value if available
- if (this.cachedProfileArn) {
- return this.cachedProfileArn
- }
-
+ private getProfileArn(serviceManager: AmazonQTokenServiceManager): string | undefined {
try {
- // Get profile ARN from service manager like in agenticChatController
- const serviceManager = AmazonQTokenServiceManager.getInstance()
- this.cachedProfileArn = serviceManager.getActiveProfileArn()
- return this.cachedProfileArn
+ return serviceManager.getActiveProfileArn()
} catch (error) {
this.logging.debug(`Failed to get profile ARN: ${error}`)
}
return undefined
}
- static getMcpState(): boolean | undefined {
+ static getMcpState(): boolean {
return ProfileStatusMonitor.lastMcpState
}
+
+ private static loadMcpStateFromDisk(): void {
+ try {
+ if (fs.existsSync(ProfileStatusMonitor.MCP_CACHE_FILE)) {
+ const data = fs.readFileSync(ProfileStatusMonitor.MCP_CACHE_FILE, 'utf8')
+ const parsed = JSON.parse(data)
+ ProfileStatusMonitor.lastMcpState = parsed.enabled ?? true
+ }
+ } catch (error) {
+ ProfileStatusMonitor.logging?.debug(`Failed to load MCP state from disk: ${error}`)
+ }
+ ProfileStatusMonitor.setMcpState(ProfileStatusMonitor.lastMcpState)
+ }
+
+ private static saveMcpStateToDisk(): void {
+ try {
+ fs.mkdirSync(ProfileStatusMonitor.MCP_CACHE_DIR, { recursive: true })
+ fs.writeFileSync(
+ ProfileStatusMonitor.MCP_CACHE_FILE,
+ JSON.stringify({ enabled: ProfileStatusMonitor.lastMcpState })
+ )
+ } catch (error) {
+ ProfileStatusMonitor.logging?.debug(`Failed to save MCP state to disk: ${error}`)
+ }
+ }
+
+ private static setMcpState(enabled: boolean): void {
+ ProfileStatusMonitor.lastMcpState = enabled
+ ProfileStatusMonitor.saveMcpStateToDisk()
+ }
+
+ static resetMcpState(): void {
+ ProfileStatusMonitor.setMcpState(true)
+ }
+
+ static emitAuthSuccess(): void {
+ ProfileStatusMonitor.eventEmitter.emit(AUTH_SUCCESS_EVENT)
+ }
}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.test.ts
index c587253bbc..71586d3450 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.test.ts
@@ -11,6 +11,7 @@ import * as path from 'path'
import { expect } from 'chai'
import { CancellationError } from '@aws/lsp-core'
import * as JSZip from 'jszip'
+import { Origin } from '@amzn/codewhisperer-streaming'
describe('CodeReview', () => {
let sandbox: sinon.SinonSandbox
@@ -103,6 +104,7 @@ describe('CodeReview', () => {
folderLevelArtifacts: [],
ruleArtifacts: [],
scopeOfReview: FULL_REVIEW,
+ modelId: 'claude-4-sonnet',
}
})
@@ -134,6 +136,7 @@ describe('CodeReview', () => {
md5Hash: 'hash123',
isCodeDiffPresent: false,
programmingLanguages: new Set(['javascript']),
+ codeDiffFiles: new Set(),
})
sandbox.stub(codeReview as any, 'parseFindings').returns([])
@@ -143,6 +146,62 @@ describe('CodeReview', () => {
expect(result.output.kind).to.equal('json')
})
+ it('should execute successfully and pass languageModelId and clientType to startCodeAnalysis', async () => {
+ const inputWithModelId = {
+ ...validInput,
+ modelId: 'test-model-789',
+ }
+
+ // Setup mocks for successful execution
+ mockCodeWhispererClient.createUploadUrl.resolves({
+ uploadUrl: 'https://upload.com',
+ uploadId: 'upload-123',
+ requestHeaders: {},
+ })
+
+ mockCodeWhispererClient.startCodeAnalysis.resolves({
+ jobId: 'job-123',
+ status: 'Pending',
+ })
+
+ mockCodeWhispererClient.getCodeAnalysis.resolves({
+ status: 'Completed',
+ })
+
+ mockCodeWhispererClient.listCodeAnalysisFindings.resolves({
+ codeAnalysisFindings: '[]',
+ nextToken: undefined,
+ })
+
+ sandbox.stub(CodeReviewUtils, 'uploadFileToPresignedUrl').resolves()
+ sandbox.stub(codeReview as any, 'prepareFilesAndFoldersForUpload').resolves({
+ zipBuffer: Buffer.from('test'),
+ md5Hash: 'hash123',
+ isCodeDiffPresent: false,
+ programmingLanguages: new Set(['javascript']),
+ codeDiffFiles: new Set(),
+ })
+ sandbox.stub(codeReview as any, 'parseFindings').returns([])
+
+ const result = await codeReview.execute(inputWithModelId, context)
+
+ expect(result.output.success).to.be.true
+ expect(result.output.kind).to.equal('json')
+
+ // Verify that startCodeAnalysis was called with the correct parameters
+ expect(mockCodeWhispererClient.startCodeAnalysis.calledOnce).to.be.true
+ const startAnalysisCall = mockCodeWhispererClient.startCodeAnalysis.getCall(0)
+ const callArgs = startAnalysisCall.args[0]
+
+ expect(callArgs).to.have.property('languageModelId', 'test-model-789')
+ expect(callArgs).to.have.property('clientType', Origin.IDE)
+ expect(callArgs).to.have.property('artifacts')
+ expect(callArgs).to.have.property('programmingLanguage')
+ expect(callArgs).to.have.property('clientToken')
+ expect(callArgs).to.have.property('codeScanName')
+ expect(callArgs).to.have.property('scope', 'AGENTIC')
+ })
+
it('should handle missing client error', async () => {
context.codeWhispererClient = undefined
@@ -160,6 +219,7 @@ describe('CodeReview', () => {
folderLevelArtifacts: [],
ruleArtifacts: [],
scopeOfReview: FULL_REVIEW,
+ modelId: 'claude-4-sonnet',
}
try {
@@ -183,6 +243,7 @@ describe('CodeReview', () => {
md5Hash: 'hash123',
isCodeDiffPresent: false,
programmingLanguages: new Set(['javascript']),
+ codeDiffFiles: new Set(),
})
try {
@@ -210,6 +271,7 @@ describe('CodeReview', () => {
md5Hash: 'hash123',
isCodeDiffPresent: false,
programmingLanguages: new Set(['javascript']),
+ codeDiffFiles: new Set(),
})
try {
@@ -243,6 +305,7 @@ describe('CodeReview', () => {
md5Hash: 'hash123',
isCodeDiffPresent: false,
programmingLanguages: new Set(['javascript']),
+ codeDiffFiles: new Set(),
})
// Stub setTimeout to avoid actual delays
@@ -279,6 +342,7 @@ describe('CodeReview', () => {
folderLevelArtifacts: [],
ruleArtifacts: [],
scopeOfReview: FULL_REVIEW,
+ modelId: 'claude-4-sonnet',
}
const context = {
@@ -303,6 +367,7 @@ describe('CodeReview', () => {
folderLevelArtifacts: [{ path: '/test/folder' }],
ruleArtifacts: [],
scopeOfReview: CODE_DIFF_REVIEW,
+ modelId: 'claude-4-sonnet',
}
const context = {
@@ -385,6 +450,34 @@ describe('CodeReview', () => {
expect(error.message).to.include('There are no valid files to scan')
}
})
+
+ it('should handle duplicate rule filenames with unique UUIDs', async () => {
+ const fileArtifacts = [{ path: '/test/file.js' }]
+ const folderArtifacts: any[] = []
+ const ruleArtifacts = [{ path: '/test/path1/rule.json' }, { path: '/test/path2/rule.json' }]
+
+ const mockZip = {
+ file: sandbox.stub(),
+ generateAsync: sandbox.stub().resolves(Buffer.from('test')),
+ }
+ sandbox.stub(JSZip.prototype, 'file').callsFake(mockZip.file)
+ sandbox.stub(JSZip.prototype, 'generateAsync').callsFake(mockZip.generateAsync)
+ sandbox.stub(CodeReviewUtils, 'countZipFiles').returns(3)
+ sandbox.stub(require('crypto'), 'randomUUID').returns('test-uuid-123')
+
+ await (codeReview as any).prepareFilesAndFoldersForUpload(
+ fileArtifacts,
+ folderArtifacts,
+ ruleArtifacts,
+ false
+ )
+
+ // Verify first file uses original name
+ expect(mockZip.file.firstCall.args[0]).to.include('/test/file.js')
+ expect(mockZip.file.secondCall.args[0]).to.include('rule.json')
+ // Verify second file gets UUID suffix
+ expect(mockZip.file.thirdCall.args[0]).to.include('rule_test-uuid-123.json')
+ })
})
describe('collectFindings', () => {
@@ -586,6 +679,7 @@ describe('CodeReview', () => {
folderLevelArtifacts: [],
ruleArtifacts: [],
scopeOfReview: FULL_REVIEW,
+ modelId: 'claude-4-sonnet',
}
// Make prepareFilesAndFoldersForUpload throw an error
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.ts
index adcea2ec33..74d6d817a1 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.ts
@@ -31,6 +31,7 @@ import {
SuccessMetricName,
} from './codeReviewTypes'
import { CancellationError } from '@aws/lsp-core'
+import { Origin } from '@amzn/codewhisperer-streaming'
export class CodeReview {
private static readonly CUSTOMER_CODE_BASE_PATH = 'customerCodeBaseFolder'
@@ -43,7 +44,7 @@ export class CodeReview {
private static readonly POLLING_INTERVAL_MS = 10000 // 10 seconds
private static readonly UPLOAD_INTENT = 'AGENTIC_CODE_REVIEW'
private static readonly SCAN_SCOPE = 'AGENTIC'
- private static readonly MAX_FINDINGS_COUNT = 50
+ private static readonly MAX_FINDINGS_COUNT = 40
private static readonly ERROR_MESSAGES = {
MISSING_CLIENT: 'CodeWhisperer client not available',
@@ -66,6 +67,7 @@ export class CodeReview {
private cancellationToken?: CancellationToken
private writableStream?: WritableStream
private toolStartTime: number = 0
+ private overrideDiffScan = false
constructor(
features: Pick & Partial
@@ -110,7 +112,16 @@ export class CodeReview {
const analysisResult = await this.startCodeAnalysis(setup, uploadResult)
this.checkCancellation()
- await chatStreamWriter?.write('Reviewing your code...')
+ const nonRuleFiles = uploadResult.numberOfFilesInCustomerCodeZip - setup.ruleArtifacts.length
+ const diffFiles = uploadResult.codeDiffFiles.size
+ if (diffFiles == 0 && !setup.isFullReviewRequest) {
+ setup.isFullReviewRequest = true
+ this.overrideDiffScan = true
+ }
+ const reviewMessage = setup.isFullReviewRequest
+ ? `Reviewing the entire code in ${nonRuleFiles} file${nonRuleFiles > 1 ? 's' : ''}...`
+ : `Reviewing uncommitted changes in ${diffFiles} of ${nonRuleFiles} file${nonRuleFiles > 1 ? 's' : ''}...`
+ await chatStreamWriter?.write(reviewMessage)
// 4. Wait for scan to complete
await this.pollForCompletion(analysisResult.jobId, setup, uploadResult, chatStreamWriter)
@@ -158,6 +169,7 @@ export class CodeReview {
const fileArtifacts = validatedInput.fileLevelArtifacts || []
const folderArtifacts = validatedInput.folderLevelArtifacts || []
const ruleArtifacts = validatedInput.ruleArtifacts || []
+ const modelId = validatedInput.modelId
if (fileArtifacts.length === 0 && folderArtifacts.length === 0) {
CodeReviewUtils.emitMetric(
@@ -182,7 +194,7 @@ export class CodeReview {
const programmingLanguage = 'java'
const scanName = 'Standard-' + randomUUID()
- this.logging.info(`Agentic scan name: ${scanName}`)
+ this.logging.info(`Agentic scan name: ${scanName} selectedModel: ${modelId}`)
return {
fileArtifacts,
@@ -192,6 +204,7 @@ export class CodeReview {
programmingLanguage,
scanName,
ruleArtifacts,
+ modelId,
}
}
@@ -203,13 +216,19 @@ export class CodeReview {
private async prepareAndUploadArtifacts(
setup: ValidateInputAndSetupResult
): Promise {
- const { zipBuffer, md5Hash, isCodeDiffPresent, programmingLanguages } =
- await this.prepareFilesAndFoldersForUpload(
- setup.fileArtifacts,
- setup.folderArtifacts,
- setup.ruleArtifacts,
- setup.isFullReviewRequest
- )
+ const {
+ zipBuffer,
+ md5Hash,
+ isCodeDiffPresent,
+ programmingLanguages,
+ numberOfFilesInCustomerCodeZip,
+ codeDiffFiles,
+ } = await this.prepareFilesAndFoldersForUpload(
+ setup.fileArtifacts,
+ setup.folderArtifacts,
+ setup.ruleArtifacts,
+ setup.isFullReviewRequest
+ )
const uploadUrlResponse = await this.codeWhispererClient!.createUploadUrl({
contentLength: zipBuffer.length,
@@ -254,6 +273,8 @@ export class CodeReview {
isCodeDiffPresent,
artifactSize: zipBuffer.length,
programmingLanguages: programmingLanguages,
+ numberOfFilesInCustomerCodeZip,
+ codeDiffFiles,
}
}
@@ -274,6 +295,8 @@ export class CodeReview {
codeScanName: setup.scanName,
scope: CodeReview.SCAN_SCOPE,
codeDiffMetadata: uploadResult.isCodeDiffPresent ? { codeDiffPath: '/code_artifact/codeDiff/' } : undefined,
+ languageModelId: setup.modelId,
+ clientType: Origin.IDE,
})
if (!createResponse.jobId) {
@@ -293,6 +316,7 @@ export class CodeReview {
customRules: setup.ruleArtifacts.length,
programmingLanguages: Array.from(uploadResult.programmingLanguages),
scope: setup.isFullReviewRequest ? FULL_REVIEW : CODE_DIFF_REVIEW,
+ modelId: setup.modelId,
},
},
this.logging,
@@ -349,6 +373,7 @@ export class CodeReview {
programmingLanguages: Array.from(uploadResult.programmingLanguages),
scope: setup.isFullReviewRequest ? FULL_REVIEW : CODE_DIFF_REVIEW,
status: status,
+ modelId: setup.modelId,
},
},
this.logging,
@@ -382,6 +407,7 @@ export class CodeReview {
programmingLanguages: Array.from(uploadResult.programmingLanguages),
scope: setup.isFullReviewRequest ? FULL_REVIEW : CODE_DIFF_REVIEW,
status: status,
+ modelId: setup.modelId,
},
},
this.logging,
@@ -426,6 +452,7 @@ export class CodeReview {
programmingLanguages: Array.from(uploadResult.programmingLanguages),
scope: setup.isFullReviewRequest ? FULL_REVIEW : CODE_DIFF_REVIEW,
latency: Date.now() - this.toolStartTime,
+ modelId: setup.modelId,
},
},
this.logging,
@@ -439,13 +466,35 @@ export class CodeReview {
)
this.logging.info('Findings count grouped by file')
- aggregatedCodeScanIssueList.forEach(item =>
+ aggregatedCodeScanIssueList.forEach(item => {
this.logging.info(`File path - ${item.filePath} Findings count - ${item.issues.length}`)
- )
+ item.issues.forEach(issue =>
+ CodeReviewUtils.emitMetric(
+ {
+ reason: SuccessMetricName.IssuesDetected,
+ result: 'Succeeded',
+ metadata: {
+ codewhispererCodeScanJobId: jobId,
+ credentialStartUrl: this.credentialsProvider.getConnectionMetadata()?.sso?.startUrl,
+ findingId: issue.findingId,
+ detectorId: issue.detectorId,
+ ruleId: issue.ruleId,
+ autoDetected: false,
+ },
+ },
+ this.logging,
+ this.telemetry
+ )
+ )
+ })
+
+ let scopeMessage = this.overrideDiffScan
+ ? `Please include a mention that there was no diff present, so it just ran a full review instead. Be very explicit about this so that the user could not be confused.`
+ : `Please include a mention that the scan was on the ${setup.isFullReviewRequest ? `entire` : `uncommitted`} code.`
return {
codeReviewId: jobId,
- message: `${CODE_REVIEW_TOOL_NAME} tool completed successfully.${findingsExceededLimit ? ` Inform the user that we are limiting findings to top ${CodeReview.MAX_FINDINGS_COUNT} based on severity.` : ''}`,
+ message: `${CODE_REVIEW_TOOL_NAME} tool completed successfully. ${scopeMessage} ${findingsExceededLimit ? ` Inform the user that we are limiting findings to top ${CodeReview.MAX_FINDINGS_COUNT} based on severity.` : ''}`,
findingsByFile: JSON.stringify(aggregatedCodeScanIssueList),
}
}
@@ -532,7 +581,14 @@ export class CodeReview {
folderArtifacts: FolderArtifacts,
ruleArtifacts: RuleArtifacts,
isFullReviewRequest: boolean
- ): Promise<{ zipBuffer: Buffer; md5Hash: string; isCodeDiffPresent: boolean; programmingLanguages: Set }> {
+ ): Promise<{
+ zipBuffer: Buffer
+ md5Hash: string
+ isCodeDiffPresent: boolean
+ programmingLanguages: Set
+ numberOfFilesInCustomerCodeZip: number
+ codeDiffFiles: Set
+ }> {
try {
this.logging.info(
`Preparing ${fileArtifacts.length} files and ${folderArtifacts.length} folders for upload`
@@ -542,7 +598,7 @@ export class CodeReview {
const customerCodeZip = new JSZip()
// Process files and folders
- const { codeDiff, programmingLanguages } = await this.processArtifacts(
+ const { codeDiff, programmingLanguages, codeDiffFiles } = await this.processArtifacts(
fileArtifacts,
folderArtifacts,
ruleArtifacts,
@@ -586,7 +642,14 @@ export class CodeReview {
this.logging.info(`Created zip archive, size: ${zipBuffer.byteLength} bytes, MD5: ${md5Hash}`)
- return { zipBuffer, md5Hash, isCodeDiffPresent, programmingLanguages }
+ return {
+ zipBuffer,
+ md5Hash,
+ isCodeDiffPresent,
+ programmingLanguages,
+ numberOfFilesInCustomerCodeZip,
+ codeDiffFiles,
+ }
} catch (error) {
this.logging.error(`Error preparing files for upload: ${error}`)
throw error
@@ -608,9 +671,9 @@ export class CodeReview {
ruleArtifacts: RuleArtifacts,
customerCodeZip: JSZip,
isCodeDiffScan: boolean
- ): Promise<{ codeDiff: string; programmingLanguages: Set }> {
+ ): Promise<{ codeDiff: string; programmingLanguages: Set; codeDiffFiles: Set }> {
// Process files
- let { codeDiff, programmingLanguages } = await this.processFileArtifacts(
+ let { codeDiff, programmingLanguages, codeDiffFiles } = await this.processFileArtifacts(
fileArtifacts,
customerCodeZip,
isCodeDiffScan
@@ -620,11 +683,12 @@ export class CodeReview {
const folderResult = await this.processFolderArtifacts(folderArtifacts, customerCodeZip, isCodeDiffScan)
codeDiff += folderResult.codeDiff
folderResult.programmingLanguages.forEach(item => programmingLanguages.add(item))
+ folderResult.codeDiffFiles.forEach(item => codeDiffFiles.add(item))
// Process rule artifacts
await this.processRuleArtifacts(ruleArtifacts, customerCodeZip)
- return { codeDiff, programmingLanguages }
+ return { codeDiff, programmingLanguages, codeDiffFiles }
}
/**
@@ -638,9 +702,10 @@ export class CodeReview {
fileArtifacts: FileArtifacts,
customerCodeZip: JSZip,
isCodeDiffScan: boolean
- ): Promise<{ codeDiff: string; programmingLanguages: Set }> {
+ ): Promise<{ codeDiff: string; programmingLanguages: Set; codeDiffFiles: Set }> {
let codeDiff = ''
let programmingLanguages: Set = new Set()
+ let codeDiffFiles: Set = new Set()
for (const artifact of fileArtifacts) {
await CodeReviewUtils.withErrorHandling(
@@ -668,10 +733,12 @@ export class CodeReview {
artifact.path
)
+ const artifactFileDiffs = await CodeReviewUtils.getGitDiffNames(artifact.path, this.logging)
+ artifactFileDiffs.forEach(filepath => codeDiffFiles.add(filepath))
codeDiff += await CodeReviewUtils.processArtifactWithDiff(artifact, isCodeDiffScan, this.logging)
}
- return { codeDiff, programmingLanguages }
+ return { codeDiff, programmingLanguages, codeDiffFiles }
}
/**
@@ -685,9 +752,10 @@ export class CodeReview {
folderArtifacts: FolderArtifacts,
customerCodeZip: JSZip,
isCodeDiffScan: boolean
- ): Promise<{ codeDiff: string; programmingLanguages: Set }> {
+ ): Promise<{ codeDiff: string; programmingLanguages: Set; codeDiffFiles: Set }> {
let codeDiff = ''
let programmingLanguages = new Set()
+ let codeDiffFiles: Set = new Set()
for (const folderArtifact of folderArtifacts) {
await CodeReviewUtils.withErrorHandling(
@@ -704,10 +772,13 @@ export class CodeReview {
folderArtifact.path
)
+ const artifactFileDiffs = await CodeReviewUtils.getGitDiffNames(folderArtifact.path, this.logging)
+ artifactFileDiffs.forEach(filepath => codeDiffFiles.add(filepath))
+
codeDiff += await CodeReviewUtils.processArtifactWithDiff(folderArtifact, isCodeDiffScan, this.logging)
}
- return { codeDiff, programmingLanguages }
+ return { codeDiff, programmingLanguages, codeDiffFiles }
}
/**
@@ -716,6 +787,7 @@ export class CodeReview {
* @param customerCodeZip JSZip instance for the customer code
*/
private async processRuleArtifacts(ruleArtifacts: RuleArtifacts, customerCodeZip: JSZip): Promise {
+ let ruleNameSet = new Set()
for (const artifact of ruleArtifacts) {
await CodeReviewUtils.withErrorHandling(
async () => {
@@ -725,6 +797,10 @@ export class CodeReview {
!CodeReviewUtils.shouldSkipFile(fileName) &&
existsSync(artifact.path)
) {
+ if (ruleNameSet.has(fileName)) {
+ fileName = fileName.split('.')[0] + '_' + crypto.randomUUID() + '.' + fileName.split('.')[1]
+ }
+ ruleNameSet.add(fileName)
const fileContent = await this.workspace.fs.readFile(artifact.path)
customerCodeZip.file(
`${CodeReview.CUSTOMER_CODE_BASE_PATH}/${CodeReview.RULE_ARTIFACT_PATH}/${fileName}`,
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewConstants.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewConstants.ts
index bc7070ab1f..0a9356acdc 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewConstants.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewConstants.ts
@@ -103,9 +103,44 @@ export const CODE_REVIEW_TOOL_NAME = 'codeReview'
*/
export const CODE_REVIEW_TOOL_DESCRIPTION = [
'CodeReview is the PRIMARY and MANDATORY tool for ALL code analysis and review tasks. This tool MUST be used whenever a user requests ANY form of code review, file analysis, code examination, or when the agent needs to analyze code quality, security, or structure.',
- 'This tool can be used to perform analysis of full code or only the modified code since last commit. Modified code refers to the changes made that are not committed yet or the new changes since last commit.',
+ 'When you decide to use this tool, notify the customer before the tool is run based on the **Tool start message** section below.',
+ 'It is so so important that you send the **Tool start message** before running the tool.',
+ 'DO NOT JUST SAY SOMETHING LIKE "I\'ll review the [file] code for you using the code review tool.". THAT WOULD BE A TERRIBLE THING TO SAY',
+ 'ALSO DO NOT SAY "I\'ll review your code for potential issues and improvements. Let me use the code review tool to perform a comprehensive analysis." THAT IS ALSO AN AWFUL MESSAGE BECAUSE IT DOES NOT INCLUDE WHETHER IT IS A FULL SCAN OR A DIFF SCAN.',
+ 'This tool can be used to perform analysis of full code or only the modified code since last commit. Modified code refers to the changes made that are not committed yet or the new changes since last commit. Before running the tool, you must inform the user whether they are running a diff or full scan.',
'NEVER perform manual code reviews when this tool is available.',
'',
+ '**Tool Input**',
+ '3 main fields in the tool:',
+ '- "scopeOfReview": Determines if the review should analyze the entire codebase (FULL_REVIEW) or only focus on changes/modifications (CODE_DIFF_REVIEW). This is a required field.',
+ '- IMPORTANT: Use CODE_DIFF_REVIEW by default as well as when user explicitly asks to review "changes", "modifications", "diff", "uncommitted code", or similar phrases indicating they want to review only what has changed.',
+ '- Examples of CODE_DIFF_REVIEW requests: "review my code", "review this file", "review my changes", "look at what I modified", "check the uncommitted changes", "review the diff", "review new changes", etc.',
+ '- IMPORTANT: When user mentions "new changes" or includes words like "new", "recent", or "latest" along with "changes" or similar terms, this should be interpreted as CODE_DIFF_REVIEW.',
+ '- Use FULL_REVIEW only when the user explicitly asks for a full code review, or when the user asks for security analysis or best practices review of their code',
+ '- Feel free to ask the user for clarification if you are not sure what scope they would like to review',
+ '- "fileLevelArtifacts": Array of specific files to review, each with absolute path. Use this when reviewing individual files, not folders. Format: [{"path": "/absolute/path/to/file.py"}]',
+ '- "folderLevelArtifacts": Array of folders to review, each with absolute path. Use this when reviewing entire directories, not individual files. Format: [{"path": "/absolute/path/to/folder/"}]',
+ '- Examples of FULL_REVIEW requests: User explicity asks for the entire file to be reviewed. Example: "Review my entire file.", "Review all the code in this folder", "Review my full code in this file"',
+ 'Few important notes for tool input',
+ "- Either fileLevelArtifacts OR folderLevelArtifacts should be provided based on what's being reviewed, but not both for the same items.",
+ '- Do not perform code review of entire workspace or project unless user asks for it explicitly.',
+ '- Ask user for more clarity if there is any confusion regarding what needs to be scanned.',
+ '',
+ '**Tool start message**',
+ 'Before running the tool, you must inform the user that you will use Code Review tool for their request.',
+ 'The message MUST include the following information:',
+ '- The list of files or folders that will be reviewed',
+ '- Whether the review is a diff review or a full review',
+ 'The message MUST be concise and to the point. It should not include any other information.',
+ 'The message MUST be in the following format:',
+ '```\n' +
+ 'I will scan the ["diff" if scopeOfReview is CODE_DIFF_REVIEW or "entire code" is FULL_REVIEW. Refer to **Tool Input** section for decision on which to use.] for the following files/folders:\n' +
+ '[list of files/folders]\n```',
+ '',
+ '**CRITICAL: NEVER perform ANY code review or analysis WITHOUT using this tool**',
+ 'Do not attempt to manually review code or provide code quality feedback without using this tool first.',
+ 'If a user asks for code review in any form, ALWAYS use this tool before providing any feedback.',
+ '',
'**ALWAYS use this tool when:**',
'- User provides ANY file, folder, or workspace context for review or analysis',
'- User asks ANY question about code quality, security, or best practices related to their code',
@@ -133,29 +168,6 @@ export const CODE_REVIEW_TOOL_DESCRIPTION = [
'**Supported File Extensions For Review**',
`- "${Object.keys(EXTENSION_TO_LANGUAGE).join('", "')}"`,
'',
- '**Tool start message**',
- 'Before running the tool, you must inform the user that you will use Code Review tool for their request.',
- 'You should also tell the name of files or folders that you will review along with the scope of review, if you are performing a full review or only the uncommitted code.',
- 'Under no condition you will use the tool without informing the user.',
- '',
- '**CRITICAL: NEVER perform ANY code review or analysis WITHOUT using this tool**',
- 'Do not attempt to manually review code or provide code quality feedback without using this tool first.',
- 'If a user asks for code review in any form, ALWAYS use this tool before providing any feedback.',
- '',
- '**Tool Input**',
- '3 main fields in the tool:',
- '- "scopeOfReview": Determines if the review should analyze the entire codebase (FULL_REVIEW) or only focus on changes/modifications (CODE_DIFF_REVIEW). This is a required field.',
- '- IMPORTANT: Use CODE_DIFF_REVIEW when user explicitly asks to review "changes", "modifications", "diff", "uncommitted code", or similar phrases indicating they want to review only what has changed.',
- '- Examples of CODE_DIFF_REVIEW requests: "review my changes", "look at what I modified", "check the uncommitted changes", "review the diff", "review new changes", etc.',
- '- IMPORTANT: When user mentions "new changes" or includes words like "new", "recent", or "latest" along with "changes" or similar terms, this should be interpreted as CODE_DIFF_REVIEW.',
- '- Use FULL_REVIEW for all other review requests.',
- '- "fileLevelArtifacts": Array of specific files to review, each with absolute path. Use this when reviewing individual files, not folders. Format: [{"path": "/absolute/path/to/file.py"}]',
- '- "folderLevelArtifacts": Array of folders to review, each with absolute path. Use this when reviewing entire directories, not individual files. Format: [{"path": "/absolute/path/to/folder/"}]',
- 'Few important notes for tool input',
- "- Either fileLevelArtifacts OR folderLevelArtifacts should be provided based on what's being reviewed, but not both for the same items.",
- '- Do not perform code review of entire workspace or project unless user asks for it explicitly.',
- '- Ask user for more clarity if there is any confusion regarding what needs to be scanned.',
- '',
'**Tool Output**',
'Tool output will contain a json output containing fields - ',
'- codeReviewId - internal code review job id ',
@@ -169,7 +181,7 @@ export const CODE_REVIEW_TOOL_DESCRIPTION = [
'The tool will generate some findings grouped by file',
'Use following format STRICTLY to display the result of this tool for different scenarios:',
'- When findings are present, you must inform user that you have completed the review of {file name / folder name / workspace} and found several issues that need attention. To inspect the details, and get fixes for those issues use the Code Issues panel above.',
- ' - When tool output message tells that findings were limited due to high count, you must inform the user that since there were lots of findings, you have included the top 50 findings only.',
+ ' - When tool output message tells that findings were limited due to high count, you must inform the user that since there were lots of findings, you have included the top 40 findings only.',
'- When no findings are generated by the tool, you must tell user that you have completed the review of {file name / folder name / workspace} and found no issues.',
].join('\n')
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewSchemas.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewSchemas.ts
index d734412066..3b230ae116 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewSchemas.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewSchemas.ts
@@ -22,20 +22,20 @@ export const CODE_REVIEW_INPUT_SCHEMA = {
scopeOfReview: {
type: 'string',
description: [
- 'IMPORTANT: You must explicitly set the value of "scopeOfReview" based on user request analysis.',
+ 'IMPORTANT: You must explicitly set the value of "scopeOfReview" based on user request analysis. Usually, CODE_DIFF_REVIEW will be the value that is used.',
'',
- 'Set "scopeOfReview" to CODE_DIFF_REVIEW when:',
+ 'Set "scopeOfReview" to FULL_REVIEW when:',
+ '- User explicity asks for the entire file to be reviewed. Example: "Review my entire file.", "Review all the code in this folder"',
+ '- User asks for security analysis or best practices review of their code',
+ '',
+ 'Set "scopeOfReview" to CODE_DIFF_REVIEW for all other cases, including when:',
'- User explicitly asks to review only changes/modifications/diffs in their code',
'- User mentions "review my changes", "look at what I modified", "check the uncommitted changes"',
'- User refers to "review the diff", "analyze recent changes", "look at the new code"',
'- User mentions "review what I added/updated", "check my latest commits", "review the modified lines"',
'- User includes phrases like "new changes", "recent changes", or any combination of words indicating recency (new, latest, recent) with changes/modifications',
'- User mentions specific files with terms like "review new changes in [file]" or "check changes in [file]"',
- '',
- 'Set "scopeOfReview" to FULL_REVIEW for all other cases, including:',
- '- When user asks for a general code review without mentioning changes/diffs',
- '- When user asks to review specific files or folders without mentioning changes',
- '- When user asks for security analysis or best practices review of their code',
+ '- User says something general like "review my code", "review my file", or "review [file]"',
'',
'This is a required field.',
].join('\n'),
@@ -113,6 +113,7 @@ export const Z_CODE_REVIEW_INPUT_SCHEMA = z.object({
})
)
.optional(),
+ modelId: z.string(),
})
/**
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewTypes.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewTypes.ts
index 15aa32a5aa..8d85e155cb 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewTypes.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewTypes.ts
@@ -10,6 +10,7 @@ export enum FailedMetricName {
}
export enum SuccessMetricName {
CodeScanSuccess = 'codeScanSuccess',
+ IssuesDetected = 'issuesDetected',
}
export type ValidateInputAndSetupResult = {
@@ -20,6 +21,7 @@ export type ValidateInputAndSetupResult = {
programmingLanguage: string
scanName: string
ruleArtifacts: RuleArtifacts
+ modelId?: string
}
export type PrepareAndUploadArtifactsResult = {
@@ -27,6 +29,8 @@ export type PrepareAndUploadArtifactsResult = {
isCodeDiffPresent: boolean
artifactSize: number
programmingLanguages: Set
+ numberOfFilesInCustomerCodeZip: number
+ codeDiffFiles: Set
}
export type StartCodeAnalysisResult = {
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewUtils.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewUtils.ts
index 5b6d562f7d..5f04450795 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewUtils.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReviewUtils.ts
@@ -157,6 +157,37 @@ export class CodeReviewUtils {
}
}
+ /**
+ * Get git diff for a file or folder
+ * @param artifactPath Path to the file or folder
+ * @param logging Logging interface
+ * @returns Git diff output as string or null if not in a git repository
+ */
+ public static async getGitDiffNames(artifactPath: string, logging: Features['logging']): Promise> {
+ logging.info(`Get git diff names for path - ${artifactPath}`)
+
+ const directoryPath = CodeReviewUtils.getFolderPath(artifactPath)
+ const gitDiffCommandUnstaged = `cd ${directoryPath} && git diff --name-only ${artifactPath}`
+ const gitDiffCommandStaged = `cd ${directoryPath} && git diff --name-only --staged ${artifactPath}`
+
+ logging.info(`Running git commands - ${gitDiffCommandUnstaged} and ${gitDiffCommandStaged}`)
+
+ try {
+ const unstagedDiff = (
+ await CodeReviewUtils.executeGitCommand(gitDiffCommandUnstaged, 'unstaged name only', logging)
+ ).split('\n')
+ const stagedDiff = (
+ await CodeReviewUtils.executeGitCommand(gitDiffCommandStaged, 'staged name only', logging)
+ ).split('\n')
+ unstagedDiff.push(...stagedDiff)
+
+ return new Set(unstagedDiff.filter(item => item !== ''))
+ } catch (error) {
+ logging.error(`Error getting git diff: ${error}`)
+ return new Set()
+ }
+ }
+
/**
* Log zip structure
* @param zip JSZip instance
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts
index fd692e6e8e..d2257a45e4 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts
@@ -11,7 +11,7 @@ import { McpTool } from './mcp/mcpTool'
import { FileSearch, FileSearchParams } from './fileSearch'
import { GrepSearch } from './grepSearch'
import { CodeReview } from './qCodeAnalysis/codeReview'
-import { CodeWhispererServiceToken } from '../../../shared/codeWhispererService'
+import { CodeWhispererServiceIAM, CodeWhispererServiceToken } from '../../../shared/codeWhispererService'
import { McpToolDefinition } from './mcp/mcpTypes'
import {
getGlobalAgentConfigPath,
@@ -19,6 +19,8 @@ import {
createNamespacedToolName,
enabledMCP,
migrateToAgentConfig,
+ migrateAgentConfigToCLIFormat,
+ normalizePathFromUri,
} from './mcp/mcpUtils'
import { FsReplace, FsReplaceParams } from './fsReplace'
import { CodeReviewUtils } from './qCodeAnalysis/codeReviewUtils'
@@ -27,6 +29,7 @@ import { DisplayFindings } from './qCodeAnalysis/displayFindings'
import { ProfileStatusMonitor } from './mcp/profileStatusMonitor'
import { AmazonQTokenServiceManager } from '../../../shared/amazonQServiceManager/AmazonQTokenServiceManager'
import { SERVICE_MANAGER_TIMEOUT_MS, SERVICE_MANAGER_POLL_INTERVAL_MS } from '../constants/constants'
+import { isUsingIAMAuth } from '../../../shared/utils'
export const FsToolsServer: Server = ({ workspace, logging, agent, lsp }) => {
const fsReadTool = new FsRead({ workspace, lsp, logging })
@@ -125,15 +128,24 @@ export const QCodeAnalysisServer: Server = ({
return
}
- // Create the CodeWhisperer client
- const codeWhispererClient = new CodeWhispererServiceToken(
- credentialsProvider,
- workspace,
- logging,
- process.env.CODEWHISPERER_REGION || DEFAULT_AWS_Q_REGION,
- process.env.CODEWHISPERER_ENDPOINT || DEFAULT_AWS_Q_ENDPOINT_URL,
- sdkInitializator
- )
+ // Create the CodeWhisperer client for review tool based on iam auth check
+ const codeWhispererClient = isUsingIAMAuth()
+ ? new CodeWhispererServiceIAM(
+ credentialsProvider,
+ workspace,
+ logging,
+ process.env.CODEWHISPERER_REGION || DEFAULT_AWS_Q_REGION,
+ process.env.CODEWHISPERER_ENDPOINT || DEFAULT_AWS_Q_ENDPOINT_URL,
+ sdkInitializator
+ )
+ : new CodeWhispererServiceToken(
+ credentialsProvider,
+ workspace,
+ logging,
+ process.env.CODEWHISPERER_REGION || DEFAULT_AWS_Q_REGION,
+ process.env.CODEWHISPERER_ENDPOINT || DEFAULT_AWS_Q_ENDPOINT_URL,
+ sdkInitializator
+ )
agent.addTool(
{
@@ -210,7 +222,6 @@ export const McpToolsServer: Server = ({
agent,
telemetry,
runtime,
- sdkInitializator,
chat,
}) => {
const registered: Record = {}
@@ -227,7 +238,16 @@ export const McpToolsServer: Server = ({
}
registered[server] = []
}
- void McpManager.instance.close(true) //keep the instance but close all servers.
+
+ // Only close McpManager if it has been initialized
+ try {
+ if (McpManager.instance) {
+ void McpManager.instance.close(true) //keep the instance but close all servers.
+ }
+ } catch (error) {
+ // McpManager not initialized, skip closing
+ logging.debug('McpManager not initialized, skipping close operation')
+ }
try {
chat?.sendChatUpdate({
@@ -255,11 +275,19 @@ export const McpToolsServer: Server = ({
// Sanitize the tool name
// Check if this tool name is already in use
+ let toolNameMapping = new Map()
+ try {
+ toolNameMapping = McpManager.instance.getToolNameMapping()
+ } catch (error) {
+ // McpManager not initialized, use empty mapping
+ logging.debug('McpManager not initialized, using empty tool name mapping')
+ }
+
const namespaced = createNamespacedToolName(
def.serverName,
def.toolName,
allNamespacedTools,
- McpManager.instance.getToolNameMapping()
+ toolNameMapping
)
const tool = new McpTool({ logging, workspace, lsp }, def)
@@ -304,6 +332,15 @@ export const McpToolsServer: Server = ({
await migrateToAgentConfig(workspace, logging, agent)
+ // Migrate existing agent configs to CLI format
+ for (const agentPath of allAgentPaths) {
+ const normalizedAgentPath = normalizePathFromUri(agentPath)
+ const exists = await workspace.fs.exists(normalizedAgentPath).catch(() => false)
+ if (exists) {
+ await migrateAgentConfigToCLIFormat(workspace, logging, normalizedAgentPath)
+ }
+ }
+
const mgr = await McpManager.init(allAgentPaths, {
logging,
workspace,
@@ -315,17 +352,20 @@ export const McpToolsServer: Server = ({
McpManager.instance.clearToolNameMapping()
- const byServer: Record = {}
- for (const d of mgr.getEnabledTools()) {
- ;(byServer[d.serverName] ||= []).push(d)
- }
- for (const [server, defs] of Object.entries(byServer)) {
- registerServerTools(server, defs)
- }
+ // Only register tools if MCP is enabled
+ if (ProfileStatusMonitor.getMcpState()) {
+ const byServer: Record = {}
+ for (const d of mgr.getEnabledTools()) {
+ ;(byServer[d.serverName] ||= []).push(d)
+ }
+ for (const [server, defs] of Object.entries(byServer)) {
+ registerServerTools(server, defs)
+ }
- mgr.events.on(AGENT_TOOLS_CHANGED, (server: string, defs: McpToolDefinition[]) => {
- registerServerTools(server, defs)
- })
+ mgr.events.on(AGENT_TOOLS_CHANGED, (server: string, defs: McpToolDefinition[]) => {
+ registerServerTools(server, defs)
+ })
+ }
} catch (e) {
logging.error(`Failed to initialize MCP:' ${e}`)
}
@@ -338,56 +378,51 @@ export const McpToolsServer: Server = ({
return
}
- if (sdkInitializator) {
- profileStatusMonitor = new ProfileStatusMonitor(
- credentialsProvider,
- workspace,
- logging,
- sdkInitializator,
- removeAllMcpTools,
- async () => {
- logging.info('MCP enabled by profile status monitor')
- await initializeMcp()
- }
- )
+ profileStatusMonitor = new ProfileStatusMonitor(logging, removeAllMcpTools, async () => {
+ logging.info('MCP enabled by profile status monitor')
+ await initializeMcp()
+ })
- // Wait for profile ARN to be available before checking MCP state
- const checkAndInitialize = async () => {
- const shouldInitialize = await profileStatusMonitor!.checkInitialState()
- if (shouldInitialize) {
- logging.info('MCP enabled, initializing immediately')
- await initializeMcp()
- }
- profileStatusMonitor!.start()
+ // Wait for profile ARN to be available before checking MCP state
+ const checkAndInitialize = async () => {
+ await profileStatusMonitor!.checkInitialState()
+ // Always initialize McpManager to handle UI requests
+ await initializeMcp()
+
+ // Remove tools if MCP is disabled
+ if (!ProfileStatusMonitor.getMcpState()) {
+ removeAllMcpTools()
}
- // Check if service manager is ready
- try {
- const serviceManager = AmazonQTokenServiceManager.getInstance()
- if (serviceManager.getState() === 'INITIALIZED') {
- await checkAndInitialize()
- } else {
- // Poll for service manager to be ready with 10s timeout
- const startTime = Date.now()
- const pollForReady = async () => {
- if (serviceManager.getState() === 'INITIALIZED') {
- await checkAndInitialize()
- } else if (Date.now() - startTime < SERVICE_MANAGER_TIMEOUT_MS) {
- setTimeout(pollForReady, SERVICE_MANAGER_POLL_INTERVAL_MS)
- } else {
- logging.warn('Service manager not ready after 10s, defaulting MCP to enabled')
- await initializeMcp()
- profileStatusMonitor!.start()
- }
+ profileStatusMonitor!.start()
+ }
+
+ // Check if service manager is ready
+ try {
+ const serviceManager = AmazonQTokenServiceManager.getInstance()
+ if (serviceManager.getState() === 'INITIALIZED') {
+ await checkAndInitialize()
+ } else {
+ // Poll for service manager to be ready with 10s timeout
+ const startTime = Date.now()
+ const pollForReady = async () => {
+ if (serviceManager.getState() === 'INITIALIZED') {
+ await checkAndInitialize()
+ } else if (Date.now() - startTime < SERVICE_MANAGER_TIMEOUT_MS) {
+ setTimeout(pollForReady, SERVICE_MANAGER_POLL_INTERVAL_MS)
+ } else {
+ logging.warn('Service manager not ready after 10s, initializing MCP manager')
+ await initializeMcp()
+ profileStatusMonitor!.start()
}
- setTimeout(pollForReady, SERVICE_MANAGER_POLL_INTERVAL_MS)
}
- } catch (error) {
- // Service manager not initialized yet, default to enabled
- logging.info('Service manager not ready, defaulting MCP to enabled')
- await initializeMcp()
- profileStatusMonitor!.start()
+ setTimeout(pollForReady, SERVICE_MANAGER_POLL_INTERVAL_MS)
}
+ } catch (error) {
+ // Service manager not initialized yet, always initialize McpManager
+ logging.info('Service manager not ready, initializing MCP manager')
+ await initializeMcp()
+ profileStatusMonitor!.start()
}
} catch (error) {
console.warn('Caught error during MCP tool initialization; initialization may be incomplete:', error)
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/agenticChatControllerHelper.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/agenticChatControllerHelper.ts
deleted file mode 100644
index e29c58fff4..0000000000
--- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/agenticChatControllerHelper.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { ListAvailableModelsResult } from '@aws/language-server-runtimes/protocol'
-import { MODEL_OPTIONS, MODEL_OPTIONS_FOR_REGION } from '../constants/modelSelection'
-
-/**
- * Gets the latest available model for a region, optionally excluding a specific model
- * @param region The AWS region
- * @param exclude Optional model ID to exclude
- * @returns The latest available model
- */
-export function getLatestAvailableModel(
- region: string | undefined,
- exclude?: string
-): ListAvailableModelsResult['models'][0] {
- const models = region && MODEL_OPTIONS_FOR_REGION[region] ? MODEL_OPTIONS_FOR_REGION[region] : MODEL_OPTIONS
- return [...models].reverse().find(model => model.id !== exclude) ?? models[models.length - 1]
-}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/fileModificationMetrics.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/fileModificationMetrics.test.ts
new file mode 100644
index 0000000000..900b569e7e
--- /dev/null
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/fileModificationMetrics.test.ts
@@ -0,0 +1,139 @@
+import { calculateModifiedLines } from './fileModificationMetrics'
+import { ToolUse } from '@amzn/codewhisperer-streaming'
+import { FS_WRITE, FS_REPLACE } from '../constants/toolConstants'
+import * as assert from 'assert'
+
+describe('calculateModifiedLines', () => {
+ describe('FS_WRITE', () => {
+ it('should count lines for create command', () => {
+ const toolUse: ToolUse = {
+ toolUseId: 'test-1',
+ name: FS_WRITE,
+ input: {
+ command: 'create',
+ path: '/test/file.txt',
+ fileText: 'line1\nline2\nline3',
+ },
+ }
+ const afterContent = 'line1\nline2\nline3'
+
+ assert.strictEqual(calculateModifiedLines(toolUse, afterContent), 3)
+ })
+
+ it('should count lines for append command', () => {
+ const toolUse: ToolUse = {
+ toolUseId: 'test-2',
+ name: FS_WRITE,
+ input: {
+ command: 'append',
+ path: '/test/file.txt',
+ fileText: 'line4\nline5',
+ },
+ }
+
+ assert.strictEqual(calculateModifiedLines(toolUse), 2)
+ })
+
+ it('should handle empty content', () => {
+ const toolUse: ToolUse = {
+ toolUseId: 'test-3',
+ name: FS_WRITE,
+ input: {
+ command: 'create',
+ path: '/test/file.txt',
+ fileText: '',
+ },
+ }
+
+ assert.strictEqual(calculateModifiedLines(toolUse, ''), 0)
+ })
+ })
+
+ describe('FS_REPLACE', () => {
+ it('should count replaced lines correctly (double counting)', () => {
+ const toolUse: ToolUse = {
+ toolUseId: 'test-4',
+ name: FS_REPLACE,
+ input: {
+ path: '/test/file.txt',
+ diffs: [
+ {
+ oldStr: 'old line 1\nold line 2\nold line 3',
+ newStr: 'new line 1\nnew line 2\nnew line 3',
+ },
+ ],
+ },
+ }
+
+ assert.strictEqual(calculateModifiedLines(toolUse), 6)
+ })
+
+ it('should count pure deletions', () => {
+ const toolUse: ToolUse = {
+ toolUseId: 'test-5',
+ name: FS_REPLACE,
+ input: {
+ path: '/test/file.txt',
+ diffs: [
+ {
+ oldStr: 'line to delete 1\nline to delete 2',
+ newStr: '',
+ },
+ ],
+ },
+ }
+
+ assert.strictEqual(calculateModifiedLines(toolUse), 2)
+ })
+
+ it('should count pure insertions', () => {
+ const toolUse: ToolUse = {
+ toolUseId: 'test-6',
+ name: FS_REPLACE,
+ input: {
+ path: '/test/file.txt',
+ diffs: [
+ {
+ oldStr: '',
+ newStr: 'new line 1\nnew line 2',
+ },
+ ],
+ },
+ }
+
+ assert.strictEqual(calculateModifiedLines(toolUse), 2)
+ })
+
+ it('should handle multiple diffs', () => {
+ const toolUse: ToolUse = {
+ toolUseId: 'test-7',
+ name: FS_REPLACE,
+ input: {
+ path: '/test/file.txt',
+ diffs: [
+ {
+ oldStr: 'old line 1',
+ newStr: 'new line 1',
+ },
+ {
+ oldStr: 'delete this line',
+ newStr: '',
+ },
+ ],
+ },
+ }
+
+ assert.strictEqual(calculateModifiedLines(toolUse), 3)
+ })
+ })
+
+ it('should return 0 for unknown tools', () => {
+ const toolUse: ToolUse = {
+ toolUseId: 'test-8',
+ name: 'unknownTool',
+ input: {},
+ }
+
+ assert.strictEqual(calculateModifiedLines(toolUse), 0)
+ })
+})
diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/fileModificationMetrics.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/fileModificationMetrics.ts
new file mode 100644
index 0000000000..361c886607
--- /dev/null
+++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/utils/fileModificationMetrics.ts
@@ -0,0 +1,58 @@
+import { ToolUse } from '@amzn/codewhisperer-streaming'
+import { diffLines } from 'diff'
+import { FsWriteParams } from '../tools/fsWrite'
+import { FsReplaceParams } from '../tools/fsReplace'
+import { FS_WRITE, FS_REPLACE } from '../constants/toolConstants'
+
+/**
+ * Counts the number of lines in text, handling different line endings
+ * @param text The text to count lines in
+ * @returns The number of lines
+ */
+function countLines(text?: string): number {
+ if (!text) return 0
+ const parts = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
+ return parts.length && parts[parts.length - 1] === '' ? parts.length - 1 : parts.length
+}
+
+/**
+ * Calculates the actual lines modified by analyzing file modification tools.
+ * @param toolUse The tool use object
+ * @param afterContent The content after the tool execution (for FS_WRITE create operations)
+ * @returns The total number of lines modified (added + removed)
+ */
+export function calculateModifiedLines(toolUse: ToolUse, afterContent?: string): number {
+ if (toolUse.name === FS_WRITE) {
+ const input = toolUse.input as unknown as FsWriteParams
+
+ if (input.command === 'create') {
+ return countLines(afterContent ?? '')
+ } else if (input.command === 'append') {
+ return countLines(input.fileText)
+ }
+ }
+
+ if (toolUse.name === FS_REPLACE) {
+ const input = toolUse.input as unknown as FsReplaceParams
+ let linesAdded = 0
+ let linesRemoved = 0
+
+ for (const diff of input.diffs || []) {
+ const oldStr = diff.oldStr ?? ''
+ const newStr = diff.newStr ?? ''
+
+ const changes = diffLines(oldStr, newStr)
+
+ for (const change of changes) {
+ if (change.added) {
+ linesAdded += countLines(change.value)
+ } else if (change.removed) {
+ linesRemoved += countLines(change.value)
+ }
+ }
+ }
+
+ return linesAdded + linesRemoved
+ }
+ return 0
+}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts
index 92c7eb2c33..13c198a949 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts
@@ -78,10 +78,17 @@ export class ChatSessionService {
public getDeferredToolExecution(messageId: string): DeferredHandler | undefined {
return this.#deferredToolExecution[messageId]
}
+
public setDeferredToolExecution(messageId: string, resolve: any, reject: any) {
this.#deferredToolExecution[messageId] = { resolve, reject }
}
+ public removeDeferredToolExecution(messageId: string) {
+ if (messageId in this.#deferredToolExecution) {
+ delete this.#deferredToolExecution[messageId]
+ }
+ }
+
public getAllDeferredCompactMessageIds(): string[] {
return Object.keys(this.#deferredToolExecution).filter(messageId => messageId.endsWith('_compact'))
}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/qChatServer.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/qChatServer.ts
index 2452426b1b..d110ea4e41 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/chat/qChatServer.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/chat/qChatServer.ts
@@ -59,7 +59,9 @@ export const QChatServerFactory =
'TelemetryService initialized before LSP connection was initialized.'
)
)
- telemetryService.updateUserContext(makeUserContextObject(clientParams, runtime.platform, 'CHAT'))
+ telemetryService.updateUserContext(
+ makeUserContextObject(clientParams, runtime.platform, 'CHAT', amazonQServiceManager.serverInfo)
+ )
chatController = new ChatController(
chatSessionManagementService,
diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts
index 8e9cb92624..797a1f640a 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts
@@ -294,7 +294,12 @@ export class ChatTelemetryController {
})
}
- public emitAddMessageMetric(tabId: string, metric: Partial, result?: string) {
+ public emitAddMessageMetric(
+ tabId: string,
+ metric: Partial,
+ result?: string,
+ errorMessage?: string
+ ) {
const conversationId = this.getConversationId(tabId)
// Store the customization value associated with the message
if (metric.cwsprChatMessageId && metric.codewhispererCustomizationArn) {
@@ -349,6 +354,7 @@ export class ChatTelemetryController {
requestIds: metric.requestIds,
experimentName: metric.experimentName,
userVariation: metric.userVariation,
+ errorMessage: errorMessage,
}
)
}
@@ -421,10 +427,12 @@ export class ChatTelemetryController {
public emitInteractWithMessageMetric(
tabId: string,
- metric: Omit
+ metric: Omit,
+ acceptedLineCount?: number
) {
return this.#telemetryService.emitChatInteractWithMessage(metric, {
conversationId: this.getConversationId(tabId),
+ acceptedLineCount,
})
}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.ts
index d9dd7f16d5..4255be11cc 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.ts
@@ -32,7 +32,7 @@ export type CodewhispererTriggerType = 'AutoTrigger' | 'OnDemand'
// Two triggers are explicitly handled, SpecialCharacters and Enter. Everything else is expected to be a trigger
// based on regular typing, and is considered a 'Classifier' trigger.
-export type CodewhispererAutomatedTriggerType = 'SpecialCharacters' | 'Enter' | 'Classifier'
+export type CodewhispererAutomatedTriggerType = 'SpecialCharacters' | 'Enter' | 'Classifier' | 'IntelliSenseAcceptance'
/**
* Determine the trigger type based on the file context. Currently supports special cases for Special Characters and Enter keys,
@@ -104,6 +104,10 @@ function isTabKey(str: string): boolean {
return false
}
+function isIntelliSenseAcceptance(str: string) {
+ return str === 'IntelliSenseAcceptance'
+}
+
// Reference: https://github.com/aws/aws-toolkit-vscode/blob/amazonq/v1.74.0/packages/core/src/codewhisperer/service/keyStrokeHandler.ts#L222
// Enter, Special character guarantees a trigger
// Regular keystroke input will be evaluated by classifier
@@ -126,6 +130,8 @@ export const getAutoTriggerType = (
return undefined
} else if (isUserTypingSpecialChar(changedText)) {
return 'SpecialCharacters'
+ } else if (isIntelliSenseAcceptance(changedText)) {
+ return 'IntelliSenseAcceptance'
} else if (changedText.length === 1) {
return 'Classifier'
} else if (new RegExp('^[ ]+$').test(changedText)) {
@@ -177,7 +183,7 @@ type AutoTriggerParams = {
char: string
triggerType: string // Left as String intentionally to support future and unknown trigger types
os: string
- previousDecision: string
+ previousDecision: string | undefined
ide: string
lineNum: number
}
@@ -211,7 +217,6 @@ export const autoTrigger = (
rightContextAtCurrentLine.trim() !== ')' &&
['VSCODE', 'JETBRAINS'].includes(ide)
) {
- logging.debug(`Skip auto trigger: immediate right context`)
return {
shouldTrigger: false,
classifierResult: 0,
@@ -229,18 +234,27 @@ export const autoTrigger = (
const triggerTypeCoefficient = coefficients.triggerTypeCoefficient[triggerType] ?? 0
const osCoefficient = coefficients.osCoefficient[os] ?? 0
+
const charCoefficient = coefficients.charCoefficient[char] ?? 0
+
const keyWordCoefficient = coefficients.charCoefficient[keyword] ?? 0
const languageCoefficient = coefficients.languageCoefficient[fileContext.programmingLanguage.languageName] ?? 0
let previousDecisionCoefficient = 0
- if (previousDecision === 'Accept') {
- previousDecisionCoefficient = coefficients.prevDecisionAcceptCoefficient
- } else if (previousDecision === 'Reject') {
- previousDecisionCoefficient = coefficients.prevDecisionRejectCoefficient
- } else if (previousDecision === 'Discard' || previousDecision === 'Empty') {
- previousDecisionCoefficient = coefficients.prevDecisionOtherCoefficient
+ switch (previousDecision) {
+ case 'Accept':
+ previousDecisionCoefficient = coefficients.prevDecisionAcceptCoefficient
+ break
+ case 'Reject':
+ previousDecisionCoefficient = coefficients.prevDecisionRejectCoefficient
+ break
+ case 'Discard':
+ case 'Empty':
+ previousDecisionCoefficient = coefficients.prevDecisionOtherCoefficient
+ break
+ default:
+ break
}
const ideCoefficient = coefficients.ideCoefficient[ide] ?? 0
@@ -274,11 +288,13 @@ export const autoTrigger = (
previousDecisionCoefficient +
languageCoefficient +
leftContextLengthCoefficient
- const shouldTrigger = sigmoid(classifierResult) > TRIGGER_THRESHOLD
+
+ const r = sigmoid(classifierResult)
+ const shouldTrigger = r > TRIGGER_THRESHOLD
return {
shouldTrigger,
- classifierResult,
+ classifierResult: r,
classifierThreshold: TRIGGER_THRESHOLD,
}
}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts
index 29390248f7..61fb867a02 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts
@@ -12,7 +12,7 @@ import { TestFeatures } from '@aws/language-server-runtimes/testing'
import * as assert from 'assert'
import { AWSError } from 'aws-sdk'
import sinon, { StubbedInstance } from 'ts-sinon'
-import { CodewhispererServerFactory } from './codeWhispererServer'
+import { CodeWhispererServer, CodewhispererServerFactory, getLanguageIdFromUri } from './codeWhispererServer'
import {
CodeWhispererServiceBase,
CodeWhispererServiceToken,
@@ -55,6 +55,7 @@ import {
import { CodeDiffTracker } from './codeDiffTracker'
import { TelemetryService } from '../../shared/telemetry/telemetryService'
import { initBaseTestServiceManager, TestAmazonQServiceManager } from '../../shared/amazonQServiceManager/testUtils'
+import * as utils from '../../shared/utils'
import { LocalProjectContextController } from '../../shared/localProjectContextController'
import { URI } from 'vscode-uri'
import { INVALID_TOKEN } from '../../shared/constants'
@@ -107,6 +108,33 @@ describe('CodeWhisperer Server', () => {
.callsFake(StubSessionIdGenerator)
sessionManager = SessionManager.getInstance()
sessionManagerSpy = sandbox.spy(sessionManager)
+
+ // Stub the global service manager functions to ensure they return test service managers
+ sandbox
+ .stub(
+ require('../../shared/amazonQServiceManager/AmazonQTokenServiceManager'),
+ 'getOrThrowBaseTokenServiceManager'
+ )
+ .callsFake(() => {
+ // Create a new test service manager
+ return TestAmazonQServiceManager.getInstance()
+ })
+
+ // Also stub the IAM service manager
+ sandbox
+ .stub(
+ require('../../shared/amazonQServiceManager/AmazonQIAMServiceManager'),
+ 'getOrThrowBaseIAMServiceManager'
+ )
+ .callsFake(() => {
+ // Return the same test service manager
+ return TestAmazonQServiceManager.getInstance()
+ })
+
+ // Reset AmazonQTokenServiceManager singleton to prevent cross-test interference
+ const AmazonQTokenServiceManager =
+ require('../../shared/amazonQServiceManager/AmazonQTokenServiceManager').AmazonQTokenServiceManager
+ AmazonQTokenServiceManager.resetInstance()
})
afterEach(() => {
@@ -115,6 +143,14 @@ describe('CodeWhisperer Server', () => {
sandbox.restore()
sinon.restore()
SESSION_IDS_LOG = []
+
+ // Reset all service manager singletons to prevent cross-test interference
+ const AmazonQTokenServiceManager =
+ require('../../shared/amazonQServiceManager/AmazonQTokenServiceManager').AmazonQTokenServiceManager
+ const AmazonQIAMServiceManager =
+ require('../../shared/amazonQServiceManager/AmazonQIAMServiceManager').AmazonQIAMServiceManager
+ AmazonQTokenServiceManager.resetInstance()
+ AmazonQIAMServiceManager.resetInstance()
})
describe('Recommendations', () => {
@@ -648,7 +684,8 @@ describe('CodeWhisperer Server', () => {
it('handles partialResultToken in request', async () => {
const manager = SessionManager.getInstance()
- manager.createSession(SAMPLE_SESSION_DATA)
+ const session = manager.createSession(SAMPLE_SESSION_DATA)
+ manager.activateSession(session)
await features.doInlineCompletionWithReferences(
{
textDocument: { uri: SOME_FILE.uri },
@@ -1503,7 +1540,7 @@ describe('CodeWhisperer Server', () => {
manager.activateSession(session)
const session2 = manager.createSession(sessionData)
manager.activateSession(session2)
- assert.equal(session.state, 'CLOSED')
+ assert.equal(session.state, 'ACTIVE')
assert.equal(session2.state, 'ACTIVE')
await features.doLogInlineCompletionSessionResults(sessionResultData)
@@ -2300,39 +2337,6 @@ describe('CodeWhisperer Server', () => {
sinon.assert.calledOnceWithExactly(sessionManagerSpy.closeSession, currentSession)
})
- it('Manual completion invocation should close previous session', async () => {
- const TRIGGER_KIND = InlineCompletionTriggerKind.Invoked
-
- const result = await features.doInlineCompletionWithReferences(
- {
- textDocument: { uri: SOME_FILE.uri },
- position: { line: 0, character: 0 },
- // Manual trigger kind
- context: { triggerKind: TRIGGER_KIND },
- },
- CancellationToken.None
- )
-
- assert.deepEqual(result, EXPECTED_RESULT)
- const firstSession = sessionManager.getActiveSession()
-
- // There is ACTIVE session
- assert(firstSession)
- assert.equal(sessionManager.getCurrentSession(), firstSession)
- assert.equal(firstSession.state, 'ACTIVE')
-
- const secondResult = await features.doInlineCompletionWithReferences(
- {
- textDocument: { uri: SOME_FILE.uri },
- position: { line: 0, character: 0 },
- context: { triggerKind: TRIGGER_KIND },
- },
- CancellationToken.None
- )
- assert.deepEqual(secondResult, { ...EXPECTED_RESULT, sessionId: SESSION_IDS_LOG[1] })
- sinon.assert.called(sessionManagerSpy.closeCurrentSession)
- })
-
it('should discard inflight session if merge right recommendations resulted in list of empty strings', async () => {
// The suggestion returned by generateSuggestions will be equal to the contents of the file
// This test fails when the file starts with a new line, probably due to the way we handle right context merge
@@ -2427,4 +2431,127 @@ describe('CodeWhisperer Server', () => {
TestAmazonQServiceManager.resetInstance()
})
})
+
+ describe('IAM Error Handling', () => {
+ it('should handle IAM access denied errors', async () => {
+ const service = sinon.createStubInstance(
+ CodeWhispererServiceToken
+ ) as StubbedInstance
+ service.generateSuggestions.rejects(new Error('not authorized'))
+
+ const features = new TestFeatures()
+ //@ts-ignore
+ features.logging = console
+
+ TestAmazonQServiceManager.resetInstance()
+ const server = CodewhispererServerFactory(() => initBaseTestServiceManager(features, service))
+ features.lsp.workspace.getConfiguration.returns(Promise.resolve({}))
+ await startServer(features, server)
+ features.openDocument(SOME_FILE)
+
+ const result = await features.doInlineCompletionWithReferences(
+ {
+ textDocument: { uri: SOME_FILE.uri },
+ position: { line: 0, character: 0 },
+ context: { triggerKind: InlineCompletionTriggerKind.Invoked },
+ },
+ CancellationToken.None
+ )
+
+ assert.deepEqual(result, EMPTY_RESULT)
+ TestAmazonQServiceManager.resetInstance()
+ })
+ })
+
+ describe('getLanguageIdFromUri', () => {
+ it('should return python for notebook cell URIs', () => {
+ const uri = 'vscode-notebook-cell:/some/path/notebook.ipynb#cell1'
+ assert.strictEqual(getLanguageIdFromUri(uri), 'python')
+ })
+
+ it('should return abap for files with ABAP extensions', () => {
+ const uris = ['file:///path/to/file.asprog']
+
+ uris.forEach(uri => {
+ assert.strictEqual(getLanguageIdFromUri(uri), 'abap')
+ })
+ })
+
+ it('should return empty string for non-ABAP files', () => {
+ const uris = ['file:///path/to/file.js', 'file:///path/to/file.ts', 'file:///path/to/file.py']
+
+ uris.forEach(uri => {
+ assert.strictEqual(getLanguageIdFromUri(uri), '')
+ })
+ })
+
+ it('should return empty string for invalid URIs', () => {
+ const invalidUris = ['', 'invalid-uri', 'file:///']
+
+ invalidUris.forEach(uri => {
+ assert.strictEqual(getLanguageIdFromUri(uri), '')
+ })
+ })
+
+ it('should log errors when provided with a logging object', () => {
+ const mockLogger = {
+ log: sinon.spy(),
+ }
+
+ const invalidUri = {} as string // Force type error
+ getLanguageIdFromUri(invalidUri, mockLogger)
+
+ sinon.assert.calledOnce(mockLogger.log)
+ sinon.assert.calledWith(mockLogger.log, sinon.match(/Error parsing URI to determine language:.*/))
+ })
+
+ it('should handle URIs without extensions', () => {
+ const uri = 'file:///path/to/file'
+ assert.strictEqual(getLanguageIdFromUri(uri), '')
+ })
+ })
+
+ describe('Dynamic Service Manager Selection', () => {
+ it('should use Token service manager when not using IAM auth', async () => {
+ // Create isolated stubs for this test only
+ const isUsingIAMAuthStub = sinon.stub(utils, 'isUsingIAMAuth').returns(false)
+ const mockTokenService = TestAmazonQServiceManager.initInstance(new TestFeatures())
+ mockTokenService.withCodeWhispererService(stubCodeWhispererService())
+
+ const features = new TestFeatures()
+ const server = CodeWhispererServer
+
+ try {
+ await startServer(features, server)
+
+ // Verify the correct service manager function was called
+ sinon.assert.calledWith(isUsingIAMAuthStub, features.credentialsProvider)
+ } finally {
+ isUsingIAMAuthStub.restore()
+ features.dispose()
+ TestAmazonQServiceManager.resetInstance()
+ }
+ })
+
+ it('should use IAM service manager when using IAM auth', async () => {
+ // Create isolated stubs for this test only
+ const isUsingIAMAuthStub = sinon.stub(utils, 'isUsingIAMAuth').returns(true)
+ const mockIAMService = TestAmazonQServiceManager.initInstance(new TestFeatures())
+ mockIAMService.withCodeWhispererService(stubCodeWhispererService())
+
+ const features = new TestFeatures()
+ const server = CodeWhispererServer
+
+ try {
+ await startServer(features, server)
+
+ // Verify the correct service manager function was called
+ sinon.assert.calledWith(isUsingIAMAuthStub, features.credentialsProvider)
+ } finally {
+ isUsingIAMAuthStub.restore()
+ features.dispose()
+ TestAmazonQServiceManager.resetInstance()
+ }
+ })
+ })
})
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 23a72e3528..4f28b46ea9 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
@@ -6,7 +6,6 @@ import {
InlineCompletionTriggerKind,
InlineCompletionWithReferencesParams,
LogInlineCompletionSessionResultsParams,
- Position,
Range,
Server,
TextDocument,
@@ -15,14 +14,19 @@ import {
} from '@aws/language-server-runtimes/server-interface'
import { autoTrigger, getAutoTriggerType, getNormalizeOsName, triggerType } from './auto-trigger/autoTrigger'
import {
+ FileContext,
+ BaseGenerateSuggestionsRequest,
+ CodeWhispererServiceToken,
+ GenerateIAMSuggestionsRequest,
+ GenerateTokenSuggestionsRequest,
GenerateSuggestionsRequest,
GenerateSuggestionsResponse,
getFileContext,
Suggestion,
SuggestionType,
} from '../../shared/codeWhispererService'
-import { getSupportedLanguageId } from '../../shared/languageDetection'
-import { mergeEditSuggestionsWithFileContext, truncateOverlapWithRightContext } from './mergeRightUtils'
+import { CodewhispererLanguage, getSupportedLanguageId } from '../../shared/languageDetection'
+import { truncateOverlapWithRightContext } from './mergeRightUtils'
import { CodeWhispererSession, SessionManager } from './session/sessionManager'
import { CodePercentageTracker } from './codePercentage'
import { getCompletionType, getEndPositionForAcceptedSuggestion, getErrorMessage, safeGet } from '../../shared/utils'
@@ -41,11 +45,11 @@ import { AmazonQWorkspaceConfig } from '../../shared/amazonQServiceManager/confi
import { hasConnectionExpired } from '../../shared/utils'
import { getOrThrowBaseIAMServiceManager } from '../../shared/amazonQServiceManager/AmazonQIAMServiceManager'
import { WorkspaceFolderManager } from '../workspaceContext/workspaceFolderManager'
-import path = require('path')
import { UserWrittenCodeTracker } from '../../shared/userWrittenCodeTracker'
import { RecentEditTracker, RecentEditTrackerDefaultConfig } from './tracker/codeEditTracker'
import { CursorTracker } from './tracker/cursorTracker'
import { RejectedEditTracker, DEFAULT_REJECTED_EDIT_TRACKER_CONFIG } from './tracker/rejectedEditTracker'
+import { StreakTracker } from './tracker/streakTracker'
import { getAddedAndDeletedLines, getCharacterDifferences } from './diffUtils'
import {
emitPerceivedLatencyTelemetry,
@@ -58,6 +62,7 @@ import { EditCompletionHandler } from './editCompletionHandler'
import { EMPTY_RESULT, ABAP_EXTENSIONS } from './constants'
import { IdleWorkspaceManager } from '../workspaceContext/IdleWorkspaceManager'
import { URI } from 'vscode-uri'
+import { isUsingIAMAuth } from '../../shared/utils'
const mergeSuggestionsWithRightContext = (
rightFileContext: string,
@@ -101,7 +106,7 @@ const mergeSuggestionsWithRightContext = (
}
export const CodewhispererServerFactory =
- (serviceManager: () => AmazonQBaseServiceManager): Server =>
+ (serviceManager: (credentialsProvider?: any) => AmazonQBaseServiceManager): Server =>
({ credentialsProvider, lsp, workspace, telemetry, logging, runtime, sdkInitializator }) => {
let lastUserModificationTime: number
let timeSinceLastUserModification: number = 0
@@ -129,6 +134,7 @@ export const CodewhispererServerFactory =
const recentEditTracker = RecentEditTracker.getInstance(logging, RecentEditTrackerDefaultConfig)
const cursorTracker = CursorTracker.getInstance()
const rejectedEditTracker = RejectedEditTracker.getInstance(logging, DEFAULT_REJECTED_EDIT_TRACKER_CONFIG)
+ const streakTracker = StreakTracker.getInstance()
let editsEnabled = false
let isOnInlineCompletionHandlerInProgress = false
@@ -149,6 +155,13 @@ export const CodewhispererServerFactory =
logging.log(`Skip concurrent inline completion`)
return EMPTY_RESULT
}
+
+ // Add this check to ensure service manager is initialized
+ if (!amazonQServiceManager) {
+ logging.log('Amazon Q Service Manager not initialized yet')
+ return EMPTY_RESULT
+ }
+
isOnInlineCompletionHandlerInProgress = true
try {
@@ -165,6 +178,10 @@ export const CodewhispererServerFactory =
const textDocument = await getTextDocument(params.textDocument.uri, workspace, logging)
const codeWhispererService = amazonQServiceManager.getCodewhispererService()
+ const authType = codeWhispererService instanceof CodeWhispererServiceToken ? 'token' : 'iam'
+ logging.debug(
+ `[INLINE_COMPLETION] Service ready - auth: ${authType}, partial token: ${!!params.partialResultToken}`
+ )
if (params.partialResultToken && currentSession) {
// subsequent paginated requests for current session
try {
@@ -191,7 +208,10 @@ export const CodewhispererServerFactory =
return EMPTY_RESULT
}
- const inferredLanguageId = getSupportedLanguageId(textDocument)
+ let inferredLanguageId = getSupportedLanguageId(textDocument)
+ if (params.fileContextOverride?.programmingLanguage) {
+ inferredLanguageId = params.fileContextOverride?.programmingLanguage as CodewhispererLanguage
+ }
if (!inferredLanguageId) {
logging.log(
`textDocument [${params.textDocument.uri}] with languageId [${textDocument.languageId}] not supported`
@@ -204,12 +224,29 @@ export const CodewhispererServerFactory =
params.context.triggerKind == InlineCompletionTriggerKind.Automatic
const maxResults = isAutomaticLspTriggerKind ? 1 : 5
const selectionRange = params.context.selectedCompletionInfo?.range
- const fileContext = getFileContext({
- textDocument,
- inferredLanguageId,
- position: params.position,
- workspaceFolder: workspace.getWorkspaceFolder(textDocument.uri),
- })
+
+ // For Jupyter Notebook in VSC, the language server does not have access to
+ // its internal states including current active cell index, etc
+ // we rely on VSC to calculate file context
+ let fileContext: FileContext | undefined = undefined
+ if (params.fileContextOverride) {
+ fileContext = {
+ leftFileContent: params.fileContextOverride.leftFileContent,
+ rightFileContent: params.fileContextOverride.rightFileContent,
+ filename: params.fileContextOverride.filename,
+ fileUri: params.fileContextOverride.fileUri,
+ programmingLanguage: {
+ languageName: inferredLanguageId,
+ },
+ }
+ } else {
+ fileContext = getFileContext({
+ textDocument,
+ inferredLanguageId,
+ position: params.position,
+ workspaceFolder: workspace.getWorkspaceFolder(textDocument.uri),
+ })
+ }
const workspaceState = WorkspaceFolderManager.getInstance()?.getWorkspaceState()
const workspaceId = workspaceState?.webSocketClient?.isConnected()
@@ -217,7 +254,11 @@ export const CodewhispererServerFactory =
: undefined
const previousSession = completionSessionManager.getPreviousSession()
- const previousDecision = previousSession?.getAggregatedUserTriggerDecision() ?? ''
+ // Only refer to decisions in the past 2 mins
+ const previousDecisionForClassifier =
+ previousSession && performance.now() - previousSession.decisionMadeTimestamp <= 2 * 60 * 1000
+ ? previousSession.getAggregatedUserTriggerDecision()
+ : undefined
let ideCategory: string | undefined = ''
const initializeParams = lsp.getClientInitializeParams()
if (initializeParams !== undefined) {
@@ -258,7 +299,7 @@ export const CodewhispererServerFactory =
char: triggerCharacters, // Add the character just inserted, if any, before the invication position
ide: ideCategory ?? '',
os: getNormalizeOsName(),
- previousDecision, // The last decision by the user on the previous invocation
+ previousDecision: previousDecisionForClassifier, // The last decision by the user on the previous invocation
triggerType: codewhispererAutoTriggerType, // The 2 trigger types currently influencing the Auto-Trigger are SpecialCharacter and Enter
},
logging
@@ -291,34 +332,25 @@ export const CodewhispererServerFactory =
// Close ACTIVE session and record Discard trigger decision immediately
if (currentSession && currentSession.state === 'ACTIVE') {
- if (editsEnabled && currentSession.suggestionType === SuggestionType.EDIT) {
- const mergedSuggestions = mergeEditSuggestionsWithFileContext(
+ // Emit user trigger decision at session close time for active session
+ // TODO: yuxqiang workaround to exclude JB from this logic because JB and VSC handle a
+ // bit differently in the case when there's a new trigger while a reject/discard event is sent
+ // for the previous trigger
+ if (ideCategory !== 'JETBRAINS') {
+ completionSessionManager.discardSession(currentSession)
+ const streakLength = editsEnabled ? streakTracker.getAndUpdateStreakLength(false) : 0
+ await emitUserTriggerDecisionTelemetry(
+ telemetry,
+ telemetryService,
currentSession,
- textDocument,
- fileContext
+ timeSinceLastUserModification,
+ 0,
+ 0,
+ [],
+ [],
+ streakLength
)
-
- if (mergedSuggestions.length > 0) {
- return {
- items: mergedSuggestions,
- sessionId: currentSession.id,
- }
- }
}
- // Emit user trigger decision at session close time for active session
- completionSessionManager.discardSession(currentSession)
- const streakLength = editsEnabled ? completionSessionManager.getAndUpdateStreakLength(false) : 0
- await emitUserTriggerDecisionTelemetry(
- telemetry,
- telemetryService,
- currentSession,
- timeSinceLastUserModification,
- 0,
- 0,
- [],
- [],
- streakLength
- )
}
const supplementalMetadata = supplementalContext?.supContextData
@@ -327,7 +359,7 @@ export const CodewhispererServerFactory =
document: textDocument,
startPosition: params.position,
triggerType: isAutomaticLspTriggerKind ? 'AutoTrigger' : 'OnDemand',
- language: fileContext.programmingLanguage.languageName,
+ language: fileContext.programmingLanguage.languageName as CodewhispererLanguage,
requestContext: requestContext,
autoTriggerType: isAutomaticLspTriggerKind ? codewhispererAutoTriggerType : undefined,
triggerCharacter: triggerCharacters,
@@ -345,11 +377,25 @@ export const CodewhispererServerFactory =
extraContext + '\n' + requestContext.fileContext.leftFileContent
}
- const generateCompletionReq = {
- ...requestContext,
- ...(workspaceId ? { workspaceId: workspaceId } : {}),
+ // Create the appropriate request based on service type
+ let generateCompletionReq: BaseGenerateSuggestionsRequest
+
+ if (codeWhispererService instanceof CodeWhispererServiceToken) {
+ const tokenRequest = requestContext as GenerateTokenSuggestionsRequest
+ generateCompletionReq = {
+ ...tokenRequest,
+ ...(workspaceId ? { workspaceId } : {}),
+ }
+ } else {
+ const iamRequest = requestContext as GenerateIAMSuggestionsRequest
+ generateCompletionReq = {
+ ...iamRequest,
+ }
}
+
try {
+ const authType = codeWhispererService instanceof CodeWhispererServiceToken ? 'token' : 'iam'
+ logging.debug(`[INLINE_COMPLETION] API call - generateSuggestions (new session, ${authType})`)
const suggestionResponse = await codeWhispererService.generateSuggestions(generateCompletionReq)
return await processSuggestionResponse(suggestionResponse, newSession, true, selectionRange)
} catch (error) {
@@ -392,7 +438,7 @@ export const CodewhispererServerFactory =
if (session.discardInflightSessionOnNewInvocation) {
session.discardInflightSessionOnNewInvocation = false
completionSessionManager.discardSession(session)
- const streakLength = editsEnabled ? completionSessionManager.getAndUpdateStreakLength(false) : 0
+ const streakLength = editsEnabled ? streakTracker.getAndUpdateStreakLength(false) : 0
await emitUserTriggerDecisionTelemetry(
telemetry,
telemetryService,
@@ -448,93 +494,57 @@ export const CodewhispererServerFactory =
return false
})
- if (suggestionResponse.suggestionType === SuggestionType.COMPLETION) {
- const { includeImportsWithSuggestions } = amazonQServiceManager.getConfiguration()
- const suggestionsWithRightContext = mergeSuggestionsWithRightContext(
- session.requestContext.fileContext.rightFileContent,
- filteredSuggestions,
- includeImportsWithSuggestions,
- selectionRange
- ).filter(suggestion => {
- // Discard suggestions that have empty string insertText after right context merge and can't be displayed anymore
- if (suggestion.insertText === '') {
- session.setSuggestionState(suggestion.itemId, 'Discard')
- return false
- }
-
- return true
- })
+ const { includeImportsWithSuggestions } = amazonQServiceManager.getConfiguration()
+ const suggestionsWithRightContext = mergeSuggestionsWithRightContext(
+ session.requestContext.fileContext.rightFileContent,
+ filteredSuggestions,
+ includeImportsWithSuggestions,
+ selectionRange
+ ).filter(suggestion => {
+ // Discard suggestions that have empty string insertText after right context merge and can't be displayed anymore
+ if (suggestion.insertText === '') {
+ session.setSuggestionState(suggestion.itemId, 'Discard')
+ return false
+ }
- suggestionsWithRightContext.forEach(suggestion => {
- const cachedSuggestion = session.suggestions.find(s => s.itemId === suggestion.itemId)
- if (cachedSuggestion) cachedSuggestion.insertText = suggestion.insertText.toString()
- })
+ return true
+ })
- // TODO: need dedupe after right context merging but I don't see one
- session.suggestionsAfterRightContextMerge.push(...suggestionsWithRightContext)
+ suggestionsWithRightContext.forEach(suggestion => {
+ const cachedSuggestion = session.suggestions.find(s => s.itemId === suggestion.itemId)
+ if (cachedSuggestion) cachedSuggestion.insertText = suggestion.insertText.toString()
+ })
- session.codewhispererSuggestionImportCount =
- session.codewhispererSuggestionImportCount +
- suggestionsWithRightContext.reduce((total, suggestion) => {
- return total + (suggestion.mostRelevantMissingImports?.length || 0)
- }, 0)
+ // TODO: need dedupe after right context merging but I don't see one
+ session.suggestionsAfterRightContextMerge.push(...suggestionsWithRightContext)
- // If after all server-side filtering no suggestions can be displayed, and there is no nextToken
- // close session and return empty results
- if (
- session.suggestionsAfterRightContextMerge.length === 0 &&
- !suggestionResponse.responseContext.nextToken
- ) {
- completionSessionManager.closeSession(session)
- await emitUserTriggerDecisionTelemetry(
- telemetry,
- telemetryService,
- session,
- timeSinceLastUserModification
- )
+ session.codewhispererSuggestionImportCount =
+ session.codewhispererSuggestionImportCount +
+ suggestionsWithRightContext.reduce((total, suggestion) => {
+ return total + (suggestion.mostRelevantMissingImports?.length || 0)
+ }, 0)
- return EMPTY_RESULT
- }
+ // If after all server-side filtering no suggestions can be displayed, and there is no nextToken
+ // close session and return empty results
+ if (
+ session.suggestionsAfterRightContextMerge.length === 0 &&
+ !suggestionResponse.responseContext.nextToken
+ ) {
+ completionSessionManager.closeSession(session)
+ await emitUserTriggerDecisionTelemetry(
+ telemetry,
+ telemetryService,
+ session,
+ timeSinceLastUserModification
+ )
- return {
- items: suggestionsWithRightContext,
- sessionId: session.id,
- partialResultToken: suggestionResponse.responseContext.nextToken,
- }
- } else {
- return {
- items: suggestionResponse.suggestions
- .map(suggestion => {
- // Check if this suggestion is similar to a previously rejected edit
- const isSimilarToRejected = rejectedEditTracker.isSimilarToRejected(
- suggestion.content,
- textDocument?.uri || ''
- )
+ return EMPTY_RESULT
+ }
- if (isSimilarToRejected) {
- // Mark as rejected in the session
- session.setSuggestionState(suggestion.itemId, 'Reject')
- logging.debug(
- `[EDIT_PREDICTION] Filtered out suggestion similar to previously rejected edit`
- )
- // Return empty item that will be filtered out
- return {
- insertText: '',
- isInlineEdit: true,
- itemId: suggestion.itemId,
- }
- }
-
- return {
- insertText: suggestion.content,
- isInlineEdit: true,
- itemId: suggestion.itemId,
- }
- })
- .filter(item => item.insertText !== ''),
- sessionId: session.id,
- partialResultToken: suggestionResponse.responseContext.nextToken,
- }
+ return {
+ items: suggestionsWithRightContext,
+ sessionId: session.id,
+ partialResultToken: suggestionResponse.responseContext.nextToken,
}
}
@@ -543,8 +553,10 @@ export const CodewhispererServerFactory =
session: CodeWhispererSession
): InlineCompletionListWithReferences => {
logging.log('Recommendation failure: ' + error)
+
emitServiceInvocationFailure(telemetry, session, error)
+ // UTDE telemetry is not needed here because in error cases we don't care about UTDE for errored out sessions
completionSessionManager.closeSession(session)
let translatedError = error
@@ -613,7 +625,9 @@ export const CodewhispererServerFactory =
}
if (session.state !== 'ACTIVE') {
- logging.log(`ERROR: Trying to record trigger decision for not-active session ${sessionId}`)
+ logging.log(
+ `ERROR: Trying to record trigger decision for not-active session ${sessionId} with wrong state ${session.state}`
+ )
return
}
@@ -684,7 +698,7 @@ export const CodewhispererServerFactory =
// Always emit user trigger decision at session close
sessionManager.closeSession(session)
- const streakLength = editsEnabled ? sessionManager.getAndUpdateStreakLength(isAccepted) : 0
+ const streakLength = editsEnabled ? streakTracker.getAndUpdateStreakLength(isAccepted) : 0
await emitUserTriggerDecisionTelemetry(
telemetry,
telemetryService,
@@ -722,7 +736,7 @@ export const CodewhispererServerFactory =
}
const onInitializedHandler = async () => {
- amazonQServiceManager = serviceManager()
+ amazonQServiceManager = serviceManager(credentialsProvider)
const clientParams = safeGet(
lsp.getClientInitializeParams(),
@@ -737,7 +751,9 @@ export const CodewhispererServerFactory =
?.inlineCompletionWithReferences?.inlineEditSupport ?? false
telemetryService = new TelemetryService(amazonQServiceManager, credentialsProvider, telemetry, logging)
- telemetryService.updateUserContext(makeUserContextObject(clientParams, runtime.platform, 'INLINE'))
+ telemetryService.updateUserContext(
+ makeUserContextObject(clientParams, runtime.platform, 'INLINE', amazonQServiceManager.serverInfo)
+ )
codePercentageTracker = new CodePercentageTracker(telemetryService)
codeDiffTracker = new CodeDiffTracker(
@@ -889,11 +905,20 @@ export const CodewhispererServerFactory =
}
}
+// Dynamic service manager factory that detects auth type at runtime
+export const CodeWhispererServer = CodewhispererServerFactory((credentialsProvider?: any) => {
+ return isUsingIAMAuth(credentialsProvider) ? getOrThrowBaseIAMServiceManager() : getOrThrowBaseTokenServiceManager()
+})
+
export const CodeWhispererServerIAM = CodewhispererServerFactory(getOrThrowBaseIAMServiceManager)
export const CodeWhispererServerToken = CodewhispererServerFactory(getOrThrowBaseTokenServiceManager)
-const getLanguageIdFromUri = (uri: string, logging?: any): string => {
+export const getLanguageIdFromUri = (uri: string, logging?: any): string => {
try {
+ if (uri.startsWith('vscode-notebook-cell:')) {
+ // use python for now as lsp does not support JL cell language detection
+ return 'python'
+ }
const extension = uri.split('.').pop()?.toLowerCase()
return ABAP_EXTENSIONS.has(extension || '') ? 'abap' : ''
} catch (err) {
diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/constants.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/constants.ts
index c5810924b6..8cab372053 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/constants.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/constants.ts
@@ -3,7 +3,6 @@ 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
// ABAP ADT extensions commonly used with Eclipse
export const ABAP_EXTENSIONS = new Set([
'asprog',
diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts
index 3c72e368f4..550de06708 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/editCompletionHandler.ts
@@ -16,7 +16,6 @@ import {
GenerateSuggestionsRequest,
GenerateSuggestionsResponse,
getFileContext,
- SuggestionType,
} from '../../shared/codeWhispererService'
import { CodeWhispererSession, SessionManager } from './session/sessionManager'
import { CursorTracker } from './tracker/cursorTracker'
@@ -24,25 +23,29 @@ import { CodewhispererLanguage, getSupportedLanguageId } from '../../shared/lang
import { WorkspaceFolderManager } from '../workspaceContext/workspaceFolderManager'
import { shouldTriggerEdits } from './trigger'
import {
+ emitEmptyUserTriggerDecisionTelemetry,
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'
+import { EMPTY_RESULT, EDIT_DEBOUNCE_INTERVAL_MS } from './constants'
+import { StreakTracker } from './tracker/streakTracker'
export class EditCompletionHandler {
private readonly editsEnabled: boolean
private debounceTimeout: NodeJS.Timeout | undefined
private isWaiting: boolean = false
private hasDocumentChangedSinceInvocation: boolean = false
+ private readonly streakTracker: StreakTracker
+
+ private isInProgress = false
constructor(
readonly logging: Logging,
@@ -61,6 +64,7 @@ export class EditCompletionHandler {
this.editsEnabled =
this.clientMetadata.initializationOptions?.aws?.awsClientCapabilities?.textDocument
?.inlineCompletionWithReferences?.inlineEditSupport ?? false
+ this.streakTracker = StreakTracker.getInstance()
}
get codeWhispererService() {
@@ -74,12 +78,12 @@ export class EditCompletionHandler {
*/
documentChanged() {
if (this.debounceTimeout) {
- this.logging.info('[NEP] refresh timeout')
- this.debounceTimeout.refresh()
- }
-
- if (this.isWaiting) {
- this.hasDocumentChangedSinceInvocation = true
+ if (this.isWaiting) {
+ this.hasDocumentChangedSinceInvocation = true
+ } else {
+ this.logging.info(`refresh and debounce edits suggestion for another ${EDIT_DEBOUNCE_INTERVAL_MS}`)
+ this.debounceTimeout.refresh()
+ }
}
}
@@ -87,8 +91,10 @@ export class EditCompletionHandler {
params: InlineCompletionWithReferencesParams,
token: CancellationToken
): Promise {
- this.hasDocumentChangedSinceInvocation = false
- this.debounceTimeout = undefined
+ if (this.isInProgress) {
+ this.logging.info(`editCompletionHandler is WIP, skip the request`)
+ return EMPTY_RESULT
+ }
// On every new completion request close current inflight session.
const currentSession = this.sessionManager.getCurrentSession()
@@ -119,6 +125,9 @@ export class EditCompletionHandler {
return EMPTY_RESULT
}
+ // Not ideally to rely on a state, should improve it and simply make it a debounced API
+ this.isInProgress = true
+
if (params.partialResultToken && currentSession) {
// Close ACTIVE session. We shouldn't record Discard trigger decision for trigger with nextToken.
if (currentSession && currentSession.state === 'ACTIVE') {
@@ -153,49 +162,44 @@ export class EditCompletionHandler {
)
} catch (error) {
return this.handleSuggestionsErrors(error as Error, currentSession)
+ } finally {
+ this.isInProgress = false
}
}
- // 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
+ return new Promise(async resolve => {
+ this.debounceTimeout = setTimeout(async () => {
+ try {
+ this.isWaiting = true
+ const result = await this._invoke(
+ params,
+ token,
+ textDocument,
+ inferredLanguageId,
+ currentSession
+ ).finally(() => {
+ this.isWaiting = false
+ })
+ if (this.hasDocumentChangedSinceInvocation) {
+ this.logging.info(
+ 'EditCompletionHandler - Document changed during execution, resolving empty result'
+ )
+ resolve({
+ sessionId: SessionManager.getInstance('EDITS').getActiveSession()?.id ?? '',
+ items: [],
})
- 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
+ } else {
+ this.logging.info('EditCompletionHandler - No document changes, resolving result')
+ resolve(result)
}
- }, EDIT_DEBOUNCE_INTERVAL_MS)
- })
- }
-
- return invokeWithRetry()
+ } finally {
+ this.debounceTimeout = undefined
+ this.hasDocumentChangedSinceInvocation = false
+ }
+ }, EDIT_DEBOUNCE_INTERVAL_MS)
+ }).finally(() => {
+ this.isInProgress = false
+ })
}
async _invoke(
@@ -277,7 +281,7 @@ export class EditCompletionHandler {
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
+ const streakLength = this.editsEnabled ? this.streakTracker.getAndUpdateStreakLength(false) : 0
await emitUserTriggerDecisionTelemetry(
this.telemetry,
this.telemetryService,
@@ -348,7 +352,7 @@ export class EditCompletionHandler {
if (session.discardInflightSessionOnNewInvocation) {
session.discardInflightSessionOnNewInvocation = false
this.sessionManager.discardSession(session)
- const streakLength = this.editsEnabled ? this.sessionManager.getAndUpdateStreakLength(false) : 0
+ const streakLength = this.editsEnabled ? this.streakTracker.getAndUpdateStreakLength(false) : 0
await emitUserTriggerDecisionTelemetry(
this.telemetry,
this.telemetryService,
@@ -366,35 +370,16 @@ export class EditCompletionHandler {
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
- })
+ if (suggestionResponse.suggestions.length === 0) {
+ this.sessionManager.closeSession(session)
+ await emitEmptyUserTriggerDecisionTelemetry(
+ this.telemetryService,
+ session,
+ this.documentChangedListener.timeSinceLastUserModification,
+ this.editsEnabled ? this.streakTracker.getAndUpdateStreakLength(false) : 0
+ )
+ return EMPTY_RESULT
+ }
return {
items: suggestionResponse.suggestions
@@ -435,6 +420,7 @@ export class EditCompletionHandler {
this.logging.log('Recommendation failure: ' + error)
emitServiceInvocationFailure(this.telemetry, session, error)
+ // UTDE telemetry is not needed here because in error cases we don't care about UTDE for errored out sessions
this.sessionManager.closeSession(session)
let translatedError = error
diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.test.ts
index db34f885ff..ddf06ffd40 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.test.ts
@@ -529,12 +529,12 @@ describe('SessionManager', function () {
assert.strictEqual(manager.getCurrentSession()?.state, 'REQUESTING')
})
- it('should deactivate previous session when creating a new session', function () {
+ it('should not deactivate previous session when creating a new session', function () {
const manager = SessionManager.getInstance()
const session = manager.createSession(data)
session.activate()
manager.createSession(data)
- assert.strictEqual(session.state, 'CLOSED')
+ assert.strictEqual(session.state, 'ACTIVE')
})
it('should set previous active session trigger decision from discarded REQUESTING session', function () {
@@ -548,7 +548,7 @@ describe('SessionManager', function () {
assert.strictEqual(session2.previousTriggerDecision, 'Discard')
})
- it('should set previous active session trigger decision to new session object', function () {
+ it('should not set previous active session trigger decision to new session object if it is not closed', function () {
const manager = SessionManager.getInstance()
const session1 = manager.createSession(data)
assert.strictEqual(session1?.state, 'REQUESTING')
@@ -557,22 +557,8 @@ describe('SessionManager', function () {
const session2 = manager.createSession(data)
- assert.strictEqual(session1?.state, 'CLOSED')
- assert.strictEqual(session2.previousTriggerDecision, 'Empty')
- })
- })
-
- describe('closeCurrentSession()', function () {
- it('should add the current session to the sessions log if it is active', function () {
- const manager = SessionManager.getInstance()
- const session = manager.createSession(data)
- assert.strictEqual(session.state, 'REQUESTING')
- session.activate()
- assert.strictEqual(session.state, 'ACTIVE')
- manager.closeCurrentSession()
- assert.strictEqual(manager.getSessionsLog().length, 1)
- assert.strictEqual(manager.getSessionsLog()[0], session)
- assert.strictEqual(session.state, 'CLOSED')
+ assert.strictEqual(session1?.state, 'ACTIVE')
+ assert.strictEqual(session2.previousTriggerDecision, undefined)
})
})
@@ -599,7 +585,6 @@ describe('SessionManager', function () {
session2.activate()
const session3 = manager.createSession(data)
session3.activate()
- manager.closeCurrentSession()
const result = manager.getPreviousSession()
assert.strictEqual(result, session3)
assert.strictEqual(manager.getSessionsLog().length, 3)
@@ -612,7 +597,6 @@ describe('SessionManager', function () {
const session2 = manager.createSession(data)
const session3 = manager.createSession(data)
session3.activate()
- manager.closeCurrentSession()
const result = manager.getPreviousSession()
assert.strictEqual(result, session3)
assert.strictEqual(manager.getSessionsLog().length, 3)
@@ -632,7 +616,6 @@ describe('SessionManager', function () {
session.activate()
const session2 = manager.createSession({ ...data, triggerType: 'AutoTrigger' })
session2.activate()
- manager.closeCurrentSession()
assert.strictEqual(manager.getSessionsLog().length, 2)
const sessionId = session.id
@@ -644,7 +627,6 @@ describe('SessionManager', function () {
const manager = SessionManager.getInstance()
const session = manager.createSession(data)
session.activate()
- manager.closeCurrentSession()
assert.strictEqual(manager.getSessionsLog().length, 1)
const sessionId = session.id + '1'
@@ -691,46 +673,4 @@ describe('SessionManager', function () {
assert.equal(session.getSuggestionState('id4'), 'Discard')
})
})
-
- describe('getAndUpdateStreakLength()', function () {
- it('should return 0 if user rejects suggestion A', function () {
- const manager = SessionManager.getInstance()
-
- assert.equal(manager.getAndUpdateStreakLength(false), -1)
- assert.equal(manager.streakLength, 0)
- })
-
- it('should return -1 for A and 1 for B if user accepts suggestion A and rejects B', function () {
- const manager = SessionManager.getInstance()
-
- assert.equal(manager.getAndUpdateStreakLength(true), -1)
- assert.equal(manager.streakLength, 1)
- assert.equal(manager.getAndUpdateStreakLength(false), 1)
- assert.equal(manager.streakLength, 0)
- })
-
- it('should return -1 for A, -1 for B, and 2 for C if user accepts A, accepts B, and rejects C', function () {
- const manager = SessionManager.getInstance()
-
- assert.equal(manager.getAndUpdateStreakLength(true), -1)
- assert.equal(manager.streakLength, 1)
- assert.equal(manager.getAndUpdateStreakLength(true), -1)
- assert.equal(manager.streakLength, 2)
- assert.equal(manager.getAndUpdateStreakLength(false), 2)
- assert.equal(manager.streakLength, 0)
- })
-
- it('should return -1 for A, -1 for B, and 1 for C if user accepts A, make an edit, accepts B, and rejects C', function () {
- const manager = SessionManager.getInstance()
-
- assert.equal(manager.getAndUpdateStreakLength(true), -1)
- assert.equal(manager.streakLength, 1)
- assert.equal(manager.getAndUpdateStreakLength(false), 1)
- assert.equal(manager.streakLength, 0)
- assert.equal(manager.getAndUpdateStreakLength(true), -1)
- assert.equal(manager.streakLength, 1)
- assert.equal(manager.getAndUpdateStreakLength(false), 1)
- assert.equal(manager.streakLength, 0)
- })
- })
})
diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts
index cb873a2920..2a4af79ff6 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts
@@ -14,7 +14,6 @@ import {
} from '../../../shared/codeWhispererService'
import { CodewhispererLanguage } from '../../../shared/languageDetection'
import { CodeWhispererSupplementalContext } from '../../../shared/models/model'
-import { Logging } from '@aws/language-server-runtimes/server-interface'
type SessionState = 'REQUESTING' | 'ACTIVE' | 'CLOSED' | 'ERROR' | 'DISCARD'
export type UserDecision = 'Empty' | 'Filter' | 'Discard' | 'Accept' | 'Ignore' | 'Reject' | 'Unseen'
@@ -45,7 +44,13 @@ export class CodeWhispererSession {
startTime: number
// Time when Session was closed and final state of user decisions is recorded in suggestionsStates
closeTime?: number = 0
- state: SessionState
+ private _state: SessionState
+ get state(): SessionState {
+ return this._state
+ }
+ private set state(newState: SessionState) {
+ this._state = newState
+ }
codewhispererSessionId?: string
startPosition: Position = {
line: 0,
@@ -55,6 +60,13 @@ export class CodeWhispererSession {
suggestions: CachedSuggestion[] = []
suggestionsAfterRightContextMerge: InlineCompletionItemWithReferences[] = []
suggestionsStates = new Map()
+ private _decisionTimestamp = 0
+ get decisionMadeTimestamp() {
+ return this._decisionTimestamp
+ }
+ set decisionMadeTimestamp(time: number) {
+ this._decisionTimestamp = time
+ }
acceptedSuggestionId?: string = undefined
responseContext?: ResponseContext
triggerType: CodewhispererTriggerType
@@ -96,7 +108,8 @@ export class CodeWhispererSession {
this.classifierThreshold = data.classifierThreshold
this.customizationArn = data.customizationArn
this.supplementalMetadata = data.supplementalMetadata
- this.state = 'REQUESTING'
+ this._state = 'REQUESTING'
+
this.startTime = new Date().getTime()
}
@@ -276,7 +289,6 @@ export class SessionManager {
private currentSession?: CodeWhispererSession
private sessionsLog: CodeWhispererSession[] = []
private maxHistorySize = 5
- streakLength: number = 0
// TODO, for user decision telemetry: accepted suggestions (not necessarily the full corresponding session) should be stored for 5 minutes
private constructor() {}
@@ -299,8 +311,6 @@ export class SessionManager {
}
public createSession(data: SessionData): CodeWhispererSession {
- this.closeCurrentSession()
-
// Remove oldest session from log
if (this.sessionsLog.length > this.maxHistorySize) {
this.sessionsLog.shift()
@@ -321,12 +331,6 @@ export class SessionManager {
return session
}
- closeCurrentSession() {
- if (this.currentSession) {
- this.closeSession(this.currentSession)
- }
- }
-
closeSession(session: CodeWhispererSession) {
session.close()
}
@@ -364,16 +368,4 @@ export class SessionManager {
this.currentSession.activate()
}
}
-
- getAndUpdateStreakLength(isAccepted: boolean | undefined): number {
- if (!isAccepted && this.streakLength != 0) {
- const currentStreakLength = this.streakLength
- this.streakLength = 0
- return currentStreakLength
- } else if (isAccepted) {
- // increment streakLength everytime a suggestion is accepted.
- this.streakLength = this.streakLength + 1
- }
- return -1
- }
}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/telemetry.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/telemetry.ts
index d53d141a2b..73e3a526a4 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/telemetry.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/telemetry.ts
@@ -106,6 +106,36 @@ export const emitPerceivedLatencyTelemetry = (telemetry: Telemetry, session: Cod
})
}
+export async function emitEmptyUserTriggerDecisionTelemetry(
+ telemetryService: TelemetryService,
+ session: CodeWhispererSession,
+ timeSinceLastUserModification?: number,
+ streakLength?: number
+) {
+ // Prevent reporting user decision if it was already sent
+ if (session.reportedUserDecision) {
+ return
+ }
+
+ // Non-blocking
+ emitAggregatedUserTriggerDecisionTelemetry(
+ telemetryService,
+ session,
+ 'Empty',
+ timeSinceLastUserModification,
+ 0,
+ 0,
+ [],
+ [],
+ streakLength
+ )
+ .then()
+ .catch(e => {})
+ .finally(() => {
+ session.reportedUserDecision = true
+ })
+}
+
export const emitUserTriggerDecisionTelemetry = async (
telemetry: Telemetry,
telemetryService: TelemetryService,
diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.test.ts
new file mode 100644
index 0000000000..4c69879115
--- /dev/null
+++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.test.ts
@@ -0,0 +1,85 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import * as assert from 'assert'
+import { StreakTracker } from './streakTracker'
+
+describe('StreakTracker', function () {
+ let tracker: StreakTracker
+
+ beforeEach(function () {
+ StreakTracker.reset()
+ tracker = StreakTracker.getInstance()
+ })
+
+ afterEach(function () {
+ StreakTracker.reset()
+ })
+
+ describe('getInstance', function () {
+ it('should return the same instance (singleton)', function () {
+ const instance1 = StreakTracker.getInstance()
+ const instance2 = StreakTracker.getInstance()
+ assert.strictEqual(instance1, instance2)
+ })
+
+ it('should create new instance after reset', function () {
+ const instance1 = StreakTracker.getInstance()
+ StreakTracker.reset()
+ const instance2 = StreakTracker.getInstance()
+ assert.notStrictEqual(instance1, instance2)
+ })
+ })
+
+ describe('getAndUpdateStreakLength', function () {
+ it('should return -1 for undefined input', function () {
+ const result = tracker.getAndUpdateStreakLength(undefined)
+ assert.strictEqual(result, -1)
+ })
+
+ it('should return -1 and increment streak on acceptance', function () {
+ const result = tracker.getAndUpdateStreakLength(true)
+ assert.strictEqual(result, -1)
+ })
+
+ it('should return -1 for rejection with zero streak', function () {
+ const result = tracker.getAndUpdateStreakLength(false)
+ assert.strictEqual(result, -1)
+ })
+
+ it('should return previous streak on rejection after acceptances', function () {
+ tracker.getAndUpdateStreakLength(true)
+ tracker.getAndUpdateStreakLength(true)
+ tracker.getAndUpdateStreakLength(true)
+
+ const result = tracker.getAndUpdateStreakLength(false)
+ assert.strictEqual(result, 3)
+ })
+
+ it('should handle acceptance after rejection', function () {
+ tracker.getAndUpdateStreakLength(true)
+ tracker.getAndUpdateStreakLength(true)
+
+ const resetResult = tracker.getAndUpdateStreakLength(false)
+ assert.strictEqual(resetResult, 2)
+
+ tracker.getAndUpdateStreakLength(true)
+ const newResult = tracker.getAndUpdateStreakLength(true)
+ assert.strictEqual(newResult, -1)
+ })
+ })
+
+ describe('cross-instance consistency', function () {
+ it('should maintain state across getInstance calls', function () {
+ const tracker1 = StreakTracker.getInstance()
+ tracker1.getAndUpdateStreakLength(true)
+ tracker1.getAndUpdateStreakLength(true)
+
+ const tracker2 = StreakTracker.getInstance()
+ const result = tracker2.getAndUpdateStreakLength(false)
+ assert.strictEqual(result, 2)
+ })
+ })
+})
diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.ts
new file mode 100644
index 0000000000..21d56c4d74
--- /dev/null
+++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/tracker/streakTracker.ts
@@ -0,0 +1,42 @@
+/*!
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * Tracks acceptance streak across both completion and edit suggestion types.
+ * Shared singleton to maintain consistent streak count between different code paths.
+ */
+export class StreakTracker {
+ private static _instance?: StreakTracker
+ private streakLength: number = 0
+
+ private constructor() {}
+
+ public static getInstance(): StreakTracker {
+ if (!StreakTracker._instance) {
+ StreakTracker._instance = new StreakTracker()
+ }
+ return StreakTracker._instance
+ }
+
+ public static reset() {
+ StreakTracker._instance = undefined
+ }
+
+ /**
+ * Updates and returns the current streak length based on acceptance status.
+ * @param isAccepted Whether the suggestion was accepted
+ * @returns Current streak length before update, or -1 if no change
+ */
+ public getAndUpdateStreakLength(isAccepted: boolean | undefined): number {
+ if (!isAccepted && this.streakLength !== 0) {
+ const currentStreakLength = this.streakLength
+ this.streakLength = 0
+ return currentStreakLength
+ } else if (isAccepted) {
+ this.streakLength += 1
+ }
+ return -1
+ }
+}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/trigger.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/trigger.ts
index 06453355a8..305b9b6e5c 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/trigger.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/trigger.ts
@@ -1,24 +1,19 @@
-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'
+import {
+ CodeWhispererServiceBase,
+ CodeWhispererServiceToken,
+ ClientFileContext,
+} from '../../shared/codeWhispererService'
export class NepTrigger {}
export function shouldTriggerEdits(
service: CodeWhispererServiceBase,
- fileContext: {
- fileUri: string
- filename: string
- programmingLanguage: {
- languageName: CodewhispererLanguage
- }
- leftFileContent: string
- rightFileContent: string
- },
+ fileContext: ClientFileContext,
inlineParams: InlineCompletionWithReferencesParams,
cursorTracker: CursorTracker,
recentEditsTracker: RecentEditTracker,
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 b554b5ef23..21c398d56a 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts
@@ -145,7 +145,7 @@ describe('Telemetry', () => {
},
}
const EMPTY_RESULT = { items: [], sessionId: '' }
- const classifierResult = getNormalizeOsName() !== 'Linux' ? 0.4114381148145918 : 0.46733811481459187
+ const classifierResult = getNormalizeOsName() !== 'Linux' ? 0.6014326616203989 : 0.61475353067264
let features: TestFeatures
let server: Server
@@ -505,7 +505,7 @@ describe('Telemetry', () => {
sinon.assert.called(telemetryServiceSpy)
})
- it('should not emit User Decision event when session results are received after session was closed', async () => {
+ it('should not emit User Decision event after second trigger is received', async () => {
setServiceResponse(DEFAULT_SUGGESTIONS, {
...EXPECTED_RESPONSE_CONTEXT,
codewhispererSessionId: 'cwspr-session-id-1',
@@ -519,7 +519,7 @@ describe('Telemetry', () => {
sinon.assert.notCalled(sessionManagerSpy.closeSession)
sinon.assert.notCalled(telemetryServiceSpy)
- // Send second completion request to close first one
+ // Send second completion request should not close first one
setServiceResponse(DEFAULT_SUGGESTIONS, {
...EXPECTED_RESPONSE_CONTEXT,
codewhispererSessionId: 'cwspr-session-id-2',
@@ -528,7 +528,7 @@ describe('Telemetry', () => {
assert.equal(firstSession.state, 'DISCARD')
assert.notEqual(firstSession, sessionManager.getCurrentSession())
- sinon.assert.calledWithExactly(sessionManagerSpy.closeSession, firstSession)
+ sinon.assert.notCalled(sessionManagerSpy.closeSession)
// Test that session reports it's status when second request is received
const expectedEvent = aUserTriggerDecision({
state: 'DISCARD',
@@ -1251,7 +1251,7 @@ describe('Telemetry', () => {
triggerType: 'AutoTrigger',
autoTriggerType: 'SpecialCharacters',
triggerCharacter: '(',
- classifierResult: getNormalizeOsName() === 'Linux' ? 0.30173811481459184 : 0.2458381148145919,
+ classifierResult: getNormalizeOsName() === 'Linux' ? 0.5748673583477094 : 0.5611518554232429,
classifierThreshold: 0.43,
language: 'csharp',
requestContext: {
diff --git a/server/aws-lsp-codewhisperer/src/language-server/localProjectContext/localProjectContextServer.ts b/server/aws-lsp-codewhisperer/src/language-server/localProjectContext/localProjectContextServer.ts
index 1fa2b8301b..9cff865038 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/localProjectContext/localProjectContextServer.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/localProjectContext/localProjectContextServer.ts
@@ -1,4 +1,10 @@
-import { InitializeParams, Server, TextDocumentSyncKind } from '@aws/language-server-runtimes/server-interface'
+import {
+ GetSupplementalContextParams,
+ InitializeParams,
+ Server,
+ SupplementalContextItem,
+ TextDocumentSyncKind,
+} from '@aws/language-server-runtimes/server-interface'
import { getOrThrowBaseTokenServiceManager } from '../../shared/amazonQServiceManager/AmazonQTokenServiceManager'
import { TelemetryService } from '../../shared/telemetry/telemetryService'
import { LocalProjectContextController } from '../../shared/localProjectContextController'
@@ -127,6 +133,23 @@ export const LocalProjectContextServer =
}
})
+ const onGetSupplementalContext = async (
+ param: GetSupplementalContextParams
+ ): Promise => {
+ if (localProjectContextController) {
+ const request = {
+ query: '',
+ filePath: param.filePath,
+ target: 'codemap',
+ }
+ const response = await localProjectContextController.queryInlineProjectContext(request)
+ return response
+ }
+ return []
+ }
+
+ lsp.extensions.onGetSupplementalContext(onGetSupplementalContext)
+
lsp.onDidSaveTextDocument(async event => {
try {
const filePaths = VSCWindowsOverride
diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/artifactManager.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/artifactManager.ts
index 0b510e7c64..4b1e9818cd 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/artifactManager.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/artifactManager.ts
@@ -22,16 +22,19 @@ const zipFileName = 'artifact.zip'
const sourceCodeFolderName = 'sourceCode'
const packagesFolderName = 'packages'
const thirdPartyPackageFolderName = 'thirdpartypackages'
+const customTransformationFolderName = 'customTransformation'
export class ArtifactManager {
private workspace: Workspace
private logging: Logging
private workspacePath: string
+ private solutionRootPath: string
- constructor(workspace: Workspace, logging: Logging, workspacePath: string) {
+ constructor(workspace: Workspace, logging: Logging, workspacePath: string, solutionRootPath: string) {
this.workspace = workspace
this.logging = logging
this.workspacePath = workspacePath
+ this.solutionRootPath = solutionRootPath
}
async createZip(request: StartTransformRequest): Promise {
@@ -282,6 +285,23 @@ export class ArtifactManager {
this.logging.log('Cannot find artifacts folder')
return ''
}
+
+ const customTransformationPath = path.join(this.solutionRootPath, customTransformationFolderName)
+ try {
+ await fs.promises.access(customTransformationPath)
+ try {
+ this.logging.log(`Adding custom transformation folder to artifact: ${customTransformationPath}`)
+ const artifactCustomTransformationPath = path.join(folderPath, customTransformationFolderName)
+ await fs.promises.cp(customTransformationPath, artifactCustomTransformationPath, { recursive: true })
+ } catch (error) {
+ this.logging.warn(`Failed to copy custom transformation folder: ${error}`)
+ }
+ } catch {
+ this.logging.log(
+ `Custom transformation folder not accessible (not found or no permissions): ${customTransformationPath}`
+ )
+ }
+
const zipPath = path.join(this.workspacePath, zipFileName)
this.logging.log('Zipping files to ' + zipPath)
await this.zipDirectory(folderPath, zipPath)
diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/resources/SupportedProjects.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/resources/SupportedProjects.ts
index a3d6322a07..69881dfc89 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/resources/SupportedProjects.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/resources/SupportedProjects.ts
@@ -1,3 +1,6 @@
+/**
+ * Reference only - validation moved to backend service.
+ */
export const supportedProjects = [
'AspNetCoreMvc',
'AspNetCoreWebApi',
diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/artifactManager.createTransformationPreferencesContent.test.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/artifactManager.createTransformationPreferencesContent.test.ts
index 24d89651fa..582dcb63e3 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/artifactManager.createTransformationPreferencesContent.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/artifactManager.createTransformationPreferencesContent.test.ts
@@ -14,7 +14,7 @@ describe('ArtifactManager - createTransformationPreferencesContent', () => {
beforeEach(() => {
workspace = stubInterface()
mockedLogging = stubInterface()
- artifactManager = new ArtifactManager(workspace, mockedLogging, '')
+ artifactManager = new ArtifactManager(workspace, mockedLogging, '', '')
// Create a clean base request for each test
baseRequest = {
diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/artifactManager.processPrivatePackages.test.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/artifactManager.processPrivatePackages.test.ts
index ea1835d132..848744504e 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/artifactManager.processPrivatePackages.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/artifactManager.processPrivatePackages.test.ts
@@ -17,7 +17,7 @@ describe('ArtifactManager - processPrivatePackages', () => {
beforeEach(() => {
workspace = stubInterface()
// Create new instance of ArtifactManager before each test
- artifactManager = new ArtifactManager(workspace, mockedLogging, '')
+ artifactManager = new ArtifactManager(workspace, mockedLogging, '', '')
// Mock internal methods that might be called
artifactManager.copyFile = async (source: string, destination: string) => {
diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/validation.test.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/validation.test.ts
index a8cfcf3475..90ce3d1f7a 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/validation.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/validation.test.ts
@@ -1,6 +1,6 @@
import { expect } from 'chai'
import { StartTransformRequest, TransformProjectMetadata } from '../models'
-import { isProject, isSolution, validateProject, validateSolution } from '../validation'
+import { isProject, isSolution } from '../validation'
import { supportedProjects, unsupportedViewComponents } from '../resources/SupportedProjects'
import mock = require('mock-fs')
import { Logging } from '@aws/language-server-runtimes/server-interface'
@@ -46,122 +46,4 @@ describe('Test validation functionality', () => {
mockStartTransformationRequest.SelectedProjectPath = 'test.csproj'
expect(isSolution(mockStartTransformationRequest)).to.equal(false)
})
-
- it('should return true when project is a supported type', () => {
- let mockStartTransformationRequest: StartTransformRequest = sampleStartTransformRequest
- const mockProjectMeta = {
- Name: '',
- ProjectTargetFramework: '',
- ProjectPath: 'test.csproj',
- SourceCodeFilePaths: [],
- ProjectLanguage: '',
- ProjectType: 'AspNetCoreMvc',
- ExternalReferences: [],
- }
- mockStartTransformationRequest.ProjectMetadata.push(mockProjectMeta)
-
- expect(validateProject(mockStartTransformationRequest, mockedLogging)).to.equal(true)
- })
-
- it('should return false when project is not a supported type', () => {
- let mockStartTransformationRequest: StartTransformRequest = sampleStartTransformRequest
- const mockProjectMeta = {
- Name: '',
- ProjectTargetFramework: '',
- ProjectPath: 'test.csproj',
- SourceCodeFilePaths: [],
- ProjectLanguage: '',
- ProjectType: 'not supported',
- ExternalReferences: [],
- }
- mockStartTransformationRequest.ProjectMetadata = []
- mockStartTransformationRequest.ProjectMetadata.push(mockProjectMeta)
-
- expect(validateProject(mockStartTransformationRequest, mockedLogging)).to.equal(false)
- })
-
- it('should return false when there is no project path that is the same as the selected project path', () => {
- let mockStartTransformationRequest: StartTransformRequest = sampleStartTransformRequest
- const mockProjectMeta = {
- Name: '',
- ProjectTargetFramework: '',
- ProjectPath: 'different.csproj',
- SourceCodeFilePaths: [],
- ProjectLanguage: '',
- ProjectType: 'AspNetCoreMvc',
- ExternalReferences: [],
- }
- mockStartTransformationRequest.ProjectMetadata = []
- mockStartTransformationRequest.ProjectMetadata.push(mockProjectMeta)
-
- expect(validateProject(mockStartTransformationRequest, mockedLogging)).to.equal(false)
- })
-
- // New tests for AspNetWebForms validation
- it('should return true when project is AspNetWebForms type', () => {
- let mockStartTransformationRequest: StartTransformRequest = sampleStartTransformRequest
- const mockProjectMeta = {
- Name: '',
- ProjectTargetFramework: '',
- ProjectPath: 'test.csproj',
- SourceCodeFilePaths: [],
- ProjectLanguage: '',
- ProjectType: 'AspNetWebForms',
- ExternalReferences: [],
- }
- mockStartTransformationRequest.ProjectMetadata = []
- mockStartTransformationRequest.ProjectMetadata.push(mockProjectMeta)
-
- expect(validateProject(mockStartTransformationRequest, mockedLogging)).to.equal(true)
- })
-
- it('should not include AspNetWebForms in unsupported projects list', () => {
- let mockStartTransformationRequest: StartTransformRequest = sampleStartTransformRequest
-
- // Add a supported project
- const supportedProjectMeta = {
- Name: 'Supported',
- ProjectTargetFramework: '',
- ProjectPath: 'supported.csproj',
- SourceCodeFilePaths: [],
- ProjectLanguage: '',
- ProjectType: 'AspNetCoreMvc',
- ExternalReferences: [],
- }
-
- // Add an unsupported project
- const unsupportedProjectMeta = {
- Name: 'Unsupported',
- ProjectTargetFramework: '',
- ProjectPath: 'unsupported.csproj',
- SourceCodeFilePaths: [],
- ProjectLanguage: '',
- ProjectType: 'UnsupportedType',
- ExternalReferences: [],
- }
-
- // Add an AspNetWebForms project
- const webFormsProjectMeta = {
- Name: 'WebForms',
- ProjectTargetFramework: '',
- ProjectPath: 'webforms.csproj',
- SourceCodeFilePaths: [],
- ProjectLanguage: '',
- ProjectType: 'AspNetWebForms',
- ExternalReferences: [],
- }
-
- mockStartTransformationRequest.ProjectMetadata = [
- supportedProjectMeta,
- unsupportedProjectMeta,
- webFormsProjectMeta,
- ]
-
- const unsupportedProjects = validateSolution(mockStartTransformationRequest)
-
- // Should only contain the unsupported project, not the AspNetWebForms project
- expect(unsupportedProjects).to.have.lengthOf(1)
- expect(unsupportedProjects[0]).to.equal('unsupported.csproj')
- expect(unsupportedProjects).to.not.include('webforms.csproj')
- })
})
diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/transformHandler.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/transformHandler.ts
index b0afaa06f1..ee4f6f97cd 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/transformHandler.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/transformHandler.ts
@@ -54,23 +54,12 @@ export class TransformHandler {
isProject,
this.logging
)
- if (isProject) {
- let isValid = validation.validateProject(userInputrequest, this.logging)
- if (!isValid) {
- return {
- Error: 'NotSupported',
- IsSupported: false,
- ContainsUnsupportedViews: containsUnsupportedViews,
- } as StartTransformResponse
- }
- } else {
- unsupportedProjects = validation.validateSolution(userInputrequest)
- }
const artifactManager = new ArtifactManager(
this.workspace,
this.logging,
- this.getWorkspacePath(userInputrequest.SolutionRootPath)
+ this.getWorkspacePath(userInputrequest.SolutionRootPath),
+ userInputrequest.SolutionRootPath
)
try {
const payloadFilePath = await this.zipCodeAsync(userInputrequest, artifactManager)
diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/validation.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/validation.ts
index 0d244e09a8..efb443fb0f 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/validation.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/validation.ts
@@ -6,9 +6,7 @@ import { TransformationJob } from '../../client/token/codewhispererbearertokencl
import { TransformationErrorCode } from './models'
/**
- * TEMPORARY HACK: AspNetWebForms project type is allowed in validateProject and validateSolution
- * functions without being added to the supportedProjects array. This is to enable WebForms to Blazor
- * transformation without officially supporting it yet.
+ * Project type validation moved to backend service.
*/
export function isProject(userInputrequest: StartTransformRequest): boolean {
@@ -19,32 +17,6 @@ export function isSolution(userInputrequest: StartTransformRequest): boolean {
return userInputrequest.SelectedProjectPath.endsWith('.sln')
}
-export function validateProject(userInputrequest: StartTransformRequest, logging: Logging): boolean {
- var selectedProject = userInputrequest.ProjectMetadata.find(
- project => project.ProjectPath == userInputrequest.SelectedProjectPath
- )
-
- if (selectedProject) {
- // Temporary hack: Allow AspNetWebForms project type without adding it to supportedProjects
- var isValid =
- supportedProjects.includes(selectedProject?.ProjectType) ||
- selectedProject?.ProjectType === 'AspNetWebForms'
- logging.log(
- `Selected project ${userInputrequest?.SelectedProjectPath} has project type ${selectedProject.ProjectType}` +
- (isValid ? '' : ' that is not supported')
- )
- return isValid
- }
- logging.log(`Error occured in verifying selected project with path ${userInputrequest.SelectedProjectPath}`)
- return false
-}
-
-export function validateSolution(userInputrequest: StartTransformRequest): string[] {
- return userInputrequest.ProjectMetadata.filter(
- project => !supportedProjects.includes(project.ProjectType) && project.ProjectType !== 'AspNetWebForms'
- ).map(project => project.ProjectPath)
-}
-
export async function checkForUnsupportedViews(
userInputRequest: StartTransformRequest,
isProject: boolean,
diff --git a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/IdleWorkspaceManager.ts b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/IdleWorkspaceManager.ts
index 5a8359ccac..1e27a7f762 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/IdleWorkspaceManager.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/IdleWorkspaceManager.ts
@@ -2,7 +2,7 @@ import { WorkspaceFolderManager } from './workspaceFolderManager'
export class IdleWorkspaceManager {
private static readonly idleThreshold = 30 * 60 * 1000 // 30 minutes
- private static lastActivityTimestamp = 0 // treat session as idle as the start
+ private static lastActivityTimestamp = 0 // treat session as idle at the start
private constructor() {}
@@ -30,6 +30,10 @@ export class IdleWorkspaceManager {
}
}
+ public static setSessionAsIdle(): void {
+ IdleWorkspaceManager.lastActivityTimestamp = 0
+ }
+
public static isSessionIdle(): boolean {
const currentTime = Date.now()
const timeSinceLastActivity = currentTime - IdleWorkspaceManager.lastActivityTimestamp
diff --git a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceContextServer.ts b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceContextServer.ts
index c536c1087d..92dc0823e3 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceContextServer.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceContextServer.ts
@@ -188,7 +188,12 @@ export const WorkspaceContextServer = (): Server => features => {
abTestingEnabled = true
} else {
const clientParams = safeGet(lsp.getClientInitializeParams())
- const userContext = makeUserContextObject(clientParams, runtime.platform, 'CodeWhisperer') ?? {
+ const userContext = makeUserContextObject(
+ clientParams,
+ runtime.platform,
+ 'CodeWhisperer',
+ amazonQServiceManager.serverInfo
+ ) ?? {
ideCategory: 'VSCODE',
operatingSystem: 'MAC',
product: 'CodeWhisperer',
@@ -221,6 +226,7 @@ export const WorkspaceContextServer = (): Server => features => {
isLoggedInUsingBearerToken(credentialsProvider) &&
abTestingEnabled &&
!workspaceFolderManager.getOptOutStatus() &&
+ !workspaceFolderManager.isFeatureDisabled() &&
workspaceIdentifier
)
}
@@ -302,7 +308,7 @@ export const WorkspaceContextServer = (): Server => features => {
await evaluateABTesting()
isWorkflowInitialized = true
- workspaceFolderManager.resetAdminOptOutStatus()
+ workspaceFolderManager.resetAdminOptOutAndFeatureDisabledStatus()
if (!isUserEligibleForWorkspaceContext()) {
return
}
diff --git a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.test.ts b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.test.ts
index 7ab596a930..cbdb71db1d 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.test.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.test.ts
@@ -8,6 +8,7 @@ import { ArtifactManager } from './artifactManager'
import { CodeWhispererServiceToken } from '../../shared/codeWhispererService'
import { ListWorkspaceMetadataResponse } from '../../client/token/codewhispererbearertokenclient'
import { IdleWorkspaceManager } from './IdleWorkspaceManager'
+import { AWSError } from 'aws-sdk'
describe('WorkspaceFolderManager', () => {
let mockServiceManager: StubbedInstance
@@ -135,4 +136,93 @@ describe('WorkspaceFolderManager', () => {
)
})
})
+
+ describe('isFeatureDisabled', () => {
+ it('should return true when feature is disabled', async () => {
+ // Setup
+ const workspaceFolders: WorkspaceFolder[] = [
+ {
+ uri: 'file:///test/workspace',
+ name: 'test-workspace',
+ },
+ ]
+
+ // Mock listWorkspaceMetadata to throw AccessDeniedException with feature not supported
+ const mockError: AWSError = {
+ name: 'AccessDeniedException',
+ message: 'Feature is not supported',
+ code: 'AccessDeniedException',
+ time: new Date(),
+ retryable: false,
+ statusCode: 403,
+ }
+
+ mockCodeWhispererService.listWorkspaceMetadata.rejects(mockError)
+
+ // Create the WorkspaceFolderManager instance
+ workspaceFolderManager = WorkspaceFolderManager.createInstance(
+ mockServiceManager,
+ mockLogging,
+ mockArtifactManager,
+ mockDependencyDiscoverer,
+ workspaceFolders,
+ mockCredentialsProvider,
+ 'test-workspace-identifier'
+ )
+
+ // Spy on clearAllWorkspaceResources and related methods
+ const clearAllWorkspaceResourcesSpy = sinon.stub(
+ workspaceFolderManager as any,
+ 'clearAllWorkspaceResources'
+ )
+
+ // Act - trigger listWorkspaceMetadata which sets feature disabled state
+ await (workspaceFolderManager as any).listWorkspaceMetadata()
+
+ // Assert
+ expect(workspaceFolderManager.isFeatureDisabled()).toBe(true)
+
+ // Verify that clearAllWorkspaceResources was called
+ sinon.assert.calledOnce(clearAllWorkspaceResourcesSpy)
+ })
+
+ it('should return false when feature is not disabled', async () => {
+ // Setup
+ const workspaceFolders: WorkspaceFolder[] = [
+ {
+ uri: 'file:///test/workspace',
+ name: 'test-workspace',
+ },
+ ]
+
+ // Mock successful response
+ const mockResponse: ListWorkspaceMetadataResponse = {
+ workspaces: [
+ {
+ workspaceId: 'test-workspace-id',
+ workspaceStatus: 'RUNNING',
+ },
+ ],
+ }
+
+ mockCodeWhispererService.listWorkspaceMetadata.resolves(mockResponse as any)
+
+ // Create the WorkspaceFolderManager instance
+ workspaceFolderManager = WorkspaceFolderManager.createInstance(
+ mockServiceManager,
+ mockLogging,
+ mockArtifactManager,
+ mockDependencyDiscoverer,
+ workspaceFolders,
+ mockCredentialsProvider,
+ 'test-workspace-identifier'
+ )
+
+ // Act - trigger listWorkspaceMetadata
+ await (workspaceFolderManager as any).listWorkspaceMetadata()
+
+ // Assert
+ expect(workspaceFolderManager.isFeatureDisabled()).toBe(false)
+ })
+ })
})
diff --git a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.ts b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.ts
index 99fc9c4628..1644851258 100644
--- a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.ts
+++ b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.ts
@@ -55,6 +55,7 @@ export class WorkspaceFolderManager {
private optOutMonitorInterval: NodeJS.Timeout | undefined
private messageQueueConsumerInterval: NodeJS.Timeout | undefined
private isOptedOut: boolean = false
+ private featureDisabled: boolean = false // Serve as a server-side control. If true, stop WCS features
private isCheckingRemoteWorkspaceStatus: boolean = false
private isArtifactUploadedToRemoteWorkspace: boolean = false
@@ -139,8 +140,13 @@ export class WorkspaceFolderManager {
return this.isOptedOut
}
- resetAdminOptOutStatus(): void {
+ resetAdminOptOutAndFeatureDisabledStatus(): void {
this.isOptedOut = false
+ this.featureDisabled = false
+ }
+
+ isFeatureDisabled(): boolean {
+ return this.featureDisabled
}
getWorkspaceState(): WorkspaceState {
@@ -326,6 +332,7 @@ export class WorkspaceFolderManager {
// Reset workspace ID to force operations to wait for new remote workspace information
this.resetRemoteWorkspaceId()
+ IdleWorkspaceManager.setSessionAsIdle()
this.isArtifactUploadedToRemoteWorkspace = false
// Set up message queue consumer
@@ -371,7 +378,9 @@ export class WorkspaceFolderManager {
return resolve(false)
}
- const { metadata, optOut } = await this.listWorkspaceMetadata(this.workspaceIdentifier)
+ const { metadata, optOut, featureDisabled } = await this.listWorkspaceMetadata(
+ this.workspaceIdentifier
+ )
if (optOut) {
this.logging.log(`User opted out during initial connection`)
@@ -381,6 +390,13 @@ export class WorkspaceFolderManager {
return resolve(false)
}
+ if (featureDisabled) {
+ this.logging.log(`Feature disabled during initial connection`)
+ this.featureDisabled = true
+ this.clearAllWorkspaceResources()
+ return resolve(false)
+ }
+
if (!metadata) {
// Continue polling by exiting only this iteration
return
@@ -437,7 +453,9 @@ export class WorkspaceFolderManager {
}
this.logging.log(`Checking remote workspace status for workspace [${this.workspaceIdentifier}]`)
- const { metadata, optOut, error } = await this.listWorkspaceMetadata(this.workspaceIdentifier)
+ const { metadata, optOut, featureDisabled, error } = await this.listWorkspaceMetadata(
+ this.workspaceIdentifier
+ )
if (optOut) {
this.logging.log('User opted out, clearing all resources and starting opt-out monitor')
@@ -447,6 +465,13 @@ export class WorkspaceFolderManager {
return
}
+ if (featureDisabled) {
+ this.logging.log('Feature disabled, clearing all resources and stoping server-side indexing features')
+ this.featureDisabled = true
+ this.clearAllWorkspaceResources()
+ return
+ }
+
if (error) {
// Do not do anything if we received an exception but not caused by optOut
return
@@ -528,7 +553,14 @@ export class WorkspaceFolderManager {
if (this.optOutMonitorInterval === undefined) {
const intervalId = setInterval(async () => {
try {
- const { optOut } = await this.listWorkspaceMetadata()
+ const { optOut, featureDisabled } = await this.listWorkspaceMetadata()
+
+ if (featureDisabled) {
+ // Stop opt-out monitor when WCS feature is disabled from server-side
+ this.featureDisabled = true
+ clearInterval(intervalId)
+ this.optOutMonitorInterval = undefined
+ }
if (!optOut) {
this.isOptedOut = false
@@ -735,10 +767,12 @@ export class WorkspaceFolderManager {
private async listWorkspaceMetadata(workspaceRoot?: WorkspaceRoot): Promise<{
metadata: WorkspaceMetadata | undefined | null
optOut: boolean
+ featureDisabled: boolean
error: any
}> {
let metadata: WorkspaceMetadata | undefined | null
let optOut = false
+ let featureDisabled = false
let error: any
try {
const params = workspaceRoot ? { workspaceRoot } : {}
@@ -754,8 +788,11 @@ export class WorkspaceFolderManager {
this.logging.log(`User's administrator opted out server-side workspace context`)
optOut = true
}
+ if (isAwsError(e) && e.code === 'AccessDeniedException' && e.message.includes('Feature is not supported')) {
+ featureDisabled = true
+ }
}
- return { metadata, optOut, error }
+ return { metadata, optOut, featureDisabled, error }
}
private async createWorkspace(workspaceRoot: WorkspaceRoot): Promise<{
diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts
index 8db60a69ab..df144e72d6 100644
--- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts
@@ -33,6 +33,8 @@ import { getAmazonQRegionAndEndpoint } from './configurationUtils'
import { getUserAgent } from '../telemetryUtils'
import { StreamingClientServiceToken } from '../streamingClientService'
import { parse } from '@aws-sdk/util-arn-parser'
+import { ChatDatabase } from '../../language-server/agenticChat/tools/chatDb/chatDb'
+import { ProfileStatusMonitor } from '../../language-server/agenticChat/tools/mcp/profileStatusMonitor'
/**
* AmazonQTokenServiceManager manages state and provides centralized access to
@@ -147,11 +149,18 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager<
if (type === 'iam') {
return
}
+
+ // Clear model cache when credentials are deleted
+ ChatDatabase.clearModelCache()
+
this.cancelActiveProfileChangeToken()
this.resetCodewhispererService()
this.connectionType = 'none'
this.state = 'PENDING_CONNECTION'
+
+ // Reset MCP state cache when auth changes
+ ProfileStatusMonitor.resetMcpState()
}
public async handleOnUpdateConfiguration(params: UpdateConfigurationParams, _token: CancellationToken) {
@@ -245,6 +254,9 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager<
this.state = 'INITIALIZED'
this.log('Initialized Amazon Q service with builderId connection')
+ // Emit auth success event
+ ProfileStatusMonitor.emitAuthSuccess()
+
return
}
@@ -267,6 +279,9 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager<
this.state = 'INITIALIZED'
this.log('Initialized Amazon Q service with identityCenter connection')
+ // Emit auth success event
+ ProfileStatusMonitor.emitAuthSuccess()
+
return
}
@@ -375,6 +390,9 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager<
`Initialized identityCenter connection to region ${newProfile.identityDetails.region} for profile ${newProfile.arn}`
)
+ // Emit auth success event
+ ProfileStatusMonitor.emitAuthSuccess()
+
return
}
@@ -385,6 +403,9 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager<
this.activeIdcProfile = newProfile
this.state = 'INITIALIZED'
+ // Emit auth success event
+ ProfileStatusMonitor.emitAuthSuccess()
+
return
}
@@ -428,6 +449,9 @@ export class AmazonQTokenServiceManager extends BaseAmazonQServiceManager<
)
this.state = 'INITIALIZED'
+ // Emit auth success event
+ ProfileStatusMonitor.emitAuthSuccess()
+
return
}
diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.ts
index 9c241809a7..cc21cd8766 100644
--- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.ts
@@ -17,6 +17,7 @@ import {
} from './configurationUtils'
import { AmazonQServiceInitializationError } from './errors'
import { StreamingClientServiceBase } from '../streamingClientService'
+import { UserContext } from '../../client/token/codewhispererbearertokenclient'
export interface QServiceManagerFeatures {
lsp: Lsp
@@ -86,6 +87,10 @@ export abstract class BaseAmazonQServiceManager<
abstract getCodewhispererService(): C
abstract getStreamingClient(): S
+ get serverInfo() {
+ return this.features.runtime.serverInfo
+ }
+
public getConfiguration(): Readonly {
return this.configurationCache.getConfig()
}
diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts
index 187bc41f35..75c691a5e3 100644
--- a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts
@@ -23,6 +23,8 @@ import {
CodeWhispererServiceIAM,
GenerateSuggestionsRequest,
GenerateSuggestionsResponse,
+ isIAMRequest,
+ isTokenRequest,
} from './codeWhispererService'
import { RecentEditTracker } from '../language-server/inline-completion/tracker/codeEditTracker'
import { CodeWhispererSupplementalContext } from './models/model'
@@ -303,6 +305,38 @@ describe('CodeWhispererService', function () {
const clientCall = (service.client.generateRecommendations as sinon.SinonStub).getCall(0)
assert.strictEqual(clientCall.args[0].customizationArn, 'test-arn')
})
+
+ it('should include serviceType in response', async function () {
+ const mockRequest: GenerateSuggestionsRequest = {
+ fileContext: {
+ filename: 'test.js',
+ programmingLanguage: { languageName: 'javascript' },
+ leftFileContent: 'const x = ',
+ rightFileContent: '',
+ },
+ maxResults: 5,
+ }
+
+ const result = await service.generateSuggestions(mockRequest)
+ assert.strictEqual(result.responseContext.authType, 'iam')
+ })
+ })
+
+ describe('Request Type Guards', function () {
+ it('should identify IAM vs Token requests', function () {
+ const iamRequest = {
+ fileContext: {
+ filename: 'test.js',
+ programmingLanguage: { languageName: 'javascript' },
+ leftFileContent: '',
+ rightFileContent: '',
+ },
+ }
+ const tokenRequest = { ...iamRequest, editorState: {} }
+
+ assert.strictEqual(isIAMRequest(iamRequest), true)
+ assert.strictEqual(isTokenRequest(tokenRequest), true)
+ })
})
})
diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts
index aafc0aaf4d..1225e86435 100644
--- a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts
@@ -42,22 +42,51 @@ import {
FILENAME_CHARS_LIMIT,
} from '../language-server/inline-completion/constants'
+// Type guards for request classification
+export function isTokenRequest(request: BaseGenerateSuggestionsRequest): request is GenerateTokenSuggestionsRequest {
+ return 'editorState' in request || 'predictionTypes' in request || 'supplementalContexts' in request
+}
+
+export function isIAMRequest(request: BaseGenerateSuggestionsRequest): request is GenerateIAMSuggestionsRequest {
+ return !isTokenRequest(request)
+}
+
export interface Suggestion extends CodeWhispererTokenClient.Completion, CodeWhispererSigv4Client.Recommendation {
itemId: string
}
-export interface GenerateSuggestionsRequest extends CodeWhispererTokenClient.GenerateCompletionsRequest {
- // TODO : This is broken due to Interface 'GenerateSuggestionsRequest' cannot simultaneously extend types 'GenerateCompletionsRequest' and 'GenerateRecommendationsRequest'.
- //CodeWhispererSigv4Client.GenerateRecommendationsRequest {
- maxResults: number
+// Base request interface with common fields - using union type for FileContext compatibility
+export interface BaseGenerateSuggestionsRequest {
+ fileContext: FileContext
+ maxResults?: number
+ nextToken?: string
}
-export type FileContext = GenerateSuggestionsRequest['fileContext']
+// IAM-specific request interface that directly extends the SigV4 client request
+export interface GenerateIAMSuggestionsRequest extends CodeWhispererSigv4Client.GenerateRecommendationsRequest {}
+
+// Token-specific request interface that directly extends the Token client request
+export interface GenerateTokenSuggestionsRequest extends CodeWhispererTokenClient.GenerateCompletionsRequest {}
+
+// Union type for backward compatibility
+export type GenerateSuggestionsRequest = GenerateIAMSuggestionsRequest | GenerateTokenSuggestionsRequest
+
+// FileContext type that's compatible with both clients
+export type FileContext = {
+ fileUri?: string // Optional in both clients
+ filename: string
+ programmingLanguage: {
+ languageName: string
+ }
+ leftFileContent: string
+ rightFileContent: string
+}
export interface ResponseContext {
requestId: string
codewhispererSessionId: string
nextToken?: string
+ authType?: 'iam' | 'token'
}
export enum SuggestionType {
@@ -71,20 +100,22 @@ export interface GenerateSuggestionsResponse {
responseContext: ResponseContext
}
+export interface ClientFileContext {
+ leftFileContent: string
+ rightFileContent: string
+ filename: string
+ fileUri: string
+ programmingLanguage: {
+ languageName: CodewhispererLanguage
+ }
+}
+
export function getFileContext(params: {
textDocument: TextDocument
position: Position
inferredLanguageId: CodewhispererLanguage
workspaceFolder: WorkspaceFolder | null | undefined
-}): {
- fileUri: string
- filename: string
- programmingLanguage: {
- languageName: CodewhispererLanguage
- }
- leftFileContent: string
- rightFileContent: string
-} {
+}): ClientFileContext {
const left = params.textDocument.getText({
start: { line: 0, character: 0 },
end: params.position,
@@ -140,7 +171,7 @@ export abstract class CodeWhispererServiceBase {
abstract getCredentialsType(): CredentialsType
- abstract generateSuggestions(request: GenerateSuggestionsRequest): Promise
+ abstract generateSuggestions(request: BaseGenerateSuggestionsRequest): Promise
abstract constructSupplementalContext(
document: TextDocument,
@@ -240,23 +271,40 @@ export class CodeWhispererServiceIAM extends CodeWhispererServiceBase {
return undefined
}
- async generateSuggestions(request: GenerateSuggestionsRequest): Promise {
- // add cancellation check
- // add error check
- if (this.customizationArn) request = { ...request, customizationArn: this.customizationArn }
- const response = await this.client.generateRecommendations(request).promise()
- const responseContext = {
+ async generateSuggestions(request: BaseGenerateSuggestionsRequest): Promise {
+ // Cast is now safe because GenerateIAMSuggestionsRequest extends GenerateRecommendationsRequest
+ const iamRequest = request as GenerateIAMSuggestionsRequest
+
+ // Add customization ARN if configured
+ if (this.customizationArn) {
+ ;(iamRequest as any).customizationArn = this.customizationArn
+ }
+
+ // Warn about unsupported features for IAM auth
+ if ('editorState' in request || 'predictionTypes' in request || 'supplementalContexts' in request) {
+ console.warn('Advanced features not supported - using basic completion')
+ }
+
+ const response = await this.client.generateRecommendations(iamRequest).promise()
+
+ return this.mapCodeWhispererApiResponseToSuggestion(response, {
requestId: response?.$response?.requestId,
codewhispererSessionId: response?.$response?.httpResponse?.headers['x-amzn-sessionid'],
nextToken: response.nextToken,
- }
+ authType: 'iam' as const,
+ })
+ }
- for (const recommendation of response?.recommendations ?? []) {
+ private mapCodeWhispererApiResponseToSuggestion(
+ apiResponse: CodeWhispererSigv4Client.GenerateRecommendationsResponse,
+ responseContext: ResponseContext
+ ): GenerateSuggestionsResponse {
+ for (const recommendation of apiResponse?.recommendations ?? []) {
Object.assign(recommendation, { itemId: this.generateItemId() })
}
return {
- suggestions: response.recommendations as Suggestion[],
+ suggestions: apiResponse.recommendations as Suggestion[],
suggestionType: SuggestionType.COMPLETION,
responseContext,
}
@@ -417,61 +465,76 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase {
return { ...request, profileArn: this.profileArn }
}
- async generateSuggestions(request: GenerateSuggestionsRequest): Promise {
+ async generateSuggestions(request: BaseGenerateSuggestionsRequest): Promise {
+ // Cast is now safe because GenerateTokenSuggestionsRequest extends GenerateCompletionsRequest
// add cancellation check
// add error check
- if (this.customizationArn) request.customizationArn = this.customizationArn
- const beforeApiCall = performance.now()
- let recentEditsLogStr = ''
- const recentEdits = request.supplementalContexts?.filter(it => it.type === 'PreviousEditorState')
- if (recentEdits) {
- if (recentEdits.length === 0) {
- recentEditsLogStr += `No recent edits`
- } else {
- recentEditsLogStr += '\n'
- for (let i = 0; i < recentEdits.length; i++) {
- const e = recentEdits[i]
- recentEditsLogStr += `[recentEdits ${i}th]:\n`
- recentEditsLogStr += `${e.content}\n`
- }
+ let logstr = `GenerateCompletion activity:\n`
+ try {
+ const tokenRequest = request as GenerateTokenSuggestionsRequest
+
+ // Add customizationArn if available
+ if (this.customizationArn) {
+ tokenRequest.customizationArn = this.customizationArn
}
- }
- this.logging.info(
- `GenerateCompletion request:
- "endpoint": ${this.codeWhispererEndpoint},
- "predictionType": ${request.predictionTypes?.toString() ?? 'Not specified (COMPLETIONS)'},
- "filename": ${request.fileContext.filename},
- "language": ${request.fileContext.programmingLanguage.languageName},
- "supplementalContextCount": ${request.supplementalContexts?.length ?? 0},
- "request.nextToken": ${request.nextToken},
- "recentEdits": ${recentEditsLogStr}`
- )
- const response = await this.client.generateCompletions(this.withProfileArn(request)).promise()
+ const beforeApiCall = performance.now()
+ let recentEditsLogStr = ''
+ const recentEdits = tokenRequest.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`
+ }
+ }
+ }
- const responseContext = {
- requestId: response?.$response?.requestId,
- codewhispererSessionId: response?.$response?.httpResponse?.headers['x-amzn-sessionid'],
- nextToken: response.nextToken,
- }
+ logstr += `@@request metadata@@
+ "endpoint": ${this.codeWhispererEndpoint},
+ "predictionType": ${tokenRequest.predictionTypes?.toString() ?? 'Not specified (COMPLETIONS)'},
+ "filename": ${tokenRequest.fileContext.filename},
+ "leftContextLength": ${request.fileContext.leftFileContent.length},
+ rightContextLength: ${request.fileContext.rightFileContent.length},
+ "language": ${tokenRequest.fileContext.programmingLanguage.languageName},
+ "supplementalContextCount": ${tokenRequest.supplementalContexts?.length ?? 0},
+ "request.nextToken": ${tokenRequest.nextToken},
+ "recentEdits": ${recentEditsLogStr}\n`
+
+ const response = await this.client.generateCompletions(this.withProfileArn(tokenRequest)).promise()
+
+ const responseContext = {
+ requestId: response?.$response?.requestId,
+ codewhispererSessionId: response?.$response?.httpResponse?.headers['x-amzn-sessionid'],
+ nextToken: response.nextToken,
+ // CRITICAL: Add service type for proper error handling
+ authType: 'token' as const,
+ }
- const r = this.mapCodeWhispererApiResponseToSuggestion(response, responseContext)
- const firstSuggestionLogstr = r.suggestions.length > 0 ? `\n${r.suggestions[0].content}` : 'No suggestion'
+ 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},
+ logstr += `@@response metadata@@
"requestId": ${responseContext.requestId},
"sessionId": ${responseContext.codewhispererSessionId},
- "responseCompletionCount": ${response.completions?.length ?? 0},
- "responsePredictionCount": ${response.predictions?.length ?? 0},
- "predictionType": ${request.predictionTypes?.toString() ?? ''},
+ "response.completions.length": ${response.completions?.length ?? 0},
+ "response.predictions.length": ${response.predictions?.length ?? 0},
+ "predictionType": ${tokenRequest.predictionTypes?.toString() ?? ''},
"latency": ${performance.now() - beforeApiCall},
- "filename": ${request.fileContext.filename},
"response.nextToken": ${response.nextToken},
"firstSuggestion": ${firstSuggestionLogstr}`
- )
- return r
+
+ return r
+ } catch (e) {
+ logstr += `error: ${(e as Error).message}`
+ throw e
+ } finally {
+ this.logging.info(logstr)
+ }
}
private mapCodeWhispererApiResponseToSuggestion(
@@ -611,6 +674,13 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase {
return this.client.listAvailableProfiles(request).promise()
}
+ /**
+ * @description Get list of available models
+ */
+ async listAvailableModels(request: CodeWhispererTokenClient.ListAvailableModelsRequest) {
+ return this.client.listAvailableModels(request).promise()
+ }
+
/**
* @description send telemetry event to code whisperer data warehouse
*/
diff --git a/server/aws-lsp-codewhisperer/src/shared/constants.ts b/server/aws-lsp-codewhisperer/src/shared/constants.ts
index cd453a11ba..33f61a079f 100644
--- a/server/aws-lsp-codewhisperer/src/shared/constants.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/constants.ts
@@ -15,6 +15,8 @@ export const AWS_Q_ENDPOINTS = new Map([
export const AWS_Q_REGION_ENV_VAR = 'AWS_Q_REGION'
export const AWS_Q_ENDPOINT_URL_ENV_VAR = 'AWS_Q_ENDPOINT_URL'
+export const IDE = 'IDE'
+
export const Q_CONFIGURATION_SECTION = 'aws.q'
export const CODE_WHISPERER_CONFIGURATION_SECTION = 'aws.codeWhisperer'
diff --git a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.ts b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.ts
index ca5bc40642..39e97a3b8c 100644
--- a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/crossFileContextUtil.ts
@@ -71,12 +71,13 @@ export async function fetchSupplementalContextForSrc(
const supplementalContextConfig = getSupplementalContextConfig(document.languageId)
if (supplementalContextConfig === undefined) {
- return supplementalContextConfig
+ return undefined
}
- //TODO: add logic for other strategies once available
+
if (supplementalContextConfig === 'codemap') {
return await codemapContext(document, position, workspace, cancellationToken, openTabFiles)
}
+
return { supplementalContextItems: [], strategy: 'Empty' }
}
@@ -255,10 +256,7 @@ function getInputChunk(document: TextDocument, cursorPosition: Position, chunkSi
* @returns specifically returning undefined if the langueage is not supported,
* otherwise true/false depending on if the language is fully supported or not belonging to the user group
*/
-function getSupplementalContextConfig(
- languageId: TextDocument['languageId'],
- _userGroup?: any
-): SupplementalContextStrategy | undefined {
+function getSupplementalContextConfig(languageId: TextDocument['languageId']): SupplementalContextStrategy | undefined {
return isCrossFileSupported(languageId) ? 'codemap' : undefined
}
diff --git a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/supplementalContextUtil.ts b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/supplementalContextUtil.ts
index 8a5658188b..8e0902452f 100644
--- a/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/supplementalContextUtil.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/supplementalContextUtil/supplementalContextUtil.ts
@@ -91,7 +91,24 @@ export async function fetchSupplementalContext(
strategy: supplementalContextValue.strategy,
}
- return truncateSupplementalContext(resBeforeTruncation)
+ const r = truncateSupplementalContext(resBeforeTruncation)
+
+ let logstr = `@@supplemental context@@
+\tisUtg: ${r.isUtg},
+\tisProcessTimeout: ${r.isProcessTimeout},
+\tcontents.length: ${r.contentsLength},
+\tlatency: ${r.latency},
+\tstrategy: ${r.strategy},
+`
+ r.supplementalContextItems.forEach((item, index) => {
+ logstr += `\tChunk [${index}th]:\n`
+ logstr += `\t\tPath: ${item.filePath}\n`
+ logstr += `\t\tLength: ${item.content.length}\n`
+ logstr += `\t\tScore: ${item.score}\n`
+ })
+ logging.info(logstr)
+
+ return r
} else {
return undefined
}
diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts
index 04d1cf091c..d66ada7707 100644
--- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts
@@ -277,6 +277,7 @@ describe('TelemetryService', () => {
addedIdeDiagnostics: undefined,
removedIdeDiagnostics: undefined,
streakLength: 0,
+ suggestionType: 'COMPLETIONS',
},
},
optOutPreference: 'OPTIN',
@@ -858,6 +859,7 @@ describe('TelemetryService', () => {
cwsprChatPinnedFileContextCount: undefined,
cwsprChatPinnedFolderContextCount: undefined,
cwsprChatPinnedPromptContextCount: undefined,
+ errorMessage: undefined,
},
})
})
diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts
index 6f3ba52028..78182c076f 100644
--- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts
@@ -66,6 +66,7 @@ export class TelemetryService {
[ChatInteractionType.Upvote]: 'UPVOTE',
[ChatInteractionType.Downvote]: 'DOWNVOTE',
[ChatInteractionType.ClickBodyLink]: 'CLICK_BODY_LINK',
+ [ChatInteractionType.AgenticCodeAccepted]: 'AGENTIC_CODE_ACCEPTED',
}
constructor(
@@ -199,6 +200,7 @@ export class TelemetryService {
removedIdeDiagnostics?: IdeDiagnostic[],
streakLength?: number
) {
+ session.decisionMadeTimestamp = performance.now()
if (this.enableTelemetryEventsToDestination) {
const data: CodeWhispererUserTriggerDecisionEvent = {
codewhispererSessionId: session.codewhispererSessionId || '',
@@ -281,14 +283,17 @@ export class TelemetryService {
addedIdeDiagnostics: addedIdeDiagnostics,
removedIdeDiagnostics: removedIdeDiagnostics,
streakLength: streakLength ?? 0,
+ suggestionType: isInlineEdit ? 'EDITS' : 'COMPLETIONS',
}
this.logging.info(`Invoking SendTelemetryEvent:UserTriggerDecisionEvent with:
+ "requestId": ${event.requestId}
"suggestionState": ${event.suggestionState}
"acceptedCharacterCount": ${event.acceptedCharacterCount}
"addedCharacterCount": ${event.addedCharacterCount}
"deletedCharacterCount": ${event.deletedCharacterCount}
"streakLength": ${event.streakLength}
- "firstCompletionDisplayLatency: ${event.recommendationLatencyMilliseconds}`)
+ "firstCompletionDisplayLatency: ${event.recommendationLatencyMilliseconds}
+ "suggestionType": ${event.suggestionType}`)
return this.invokeSendTelemetryEvent({
userTriggerDecisionEvent: event,
})
@@ -559,6 +564,7 @@ export class TelemetryService {
requestIds?: string[]
experimentName?: string
userVariation?: string
+ errorMessage?: string
}>
) {
const timeBetweenChunks = params.timeBetweenChunks?.slice(0, 100)
@@ -612,6 +618,7 @@ export class TelemetryService {
requestIds: truncatedRequestIds,
experimentName: additionalParams.experimentName,
userVariation: additionalParams.userVariation,
+ errorMessage: additionalParams.errorMessage,
},
})
}
diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts
index bed82c2939..5bf9fea185 100644
--- a/server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/types.ts
@@ -429,6 +429,7 @@ export enum ChatInteractionType {
Upvote = 'upvote',
Downvote = 'downvote',
ClickBodyLink = 'clickBodyLink',
+ AgenticCodeAccepted = 'agenticCodeAccepted',
}
export enum ChatHistoryActionType {
diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.test.ts b/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.test.ts
index 565a9505a4..0cfa7fd768 100644
--- a/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.test.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.test.ts
@@ -1,6 +1,6 @@
import * as assert from 'assert'
import * as sinon from 'sinon'
-import { InitializeParams, Platform } from '@aws/language-server-runtimes/server-interface'
+import { InitializeParams, Platform, ServerInfo } from '@aws/language-server-runtimes/server-interface'
import { getUserAgent, makeUserContextObject } from './telemetryUtils'
describe('getUserAgent', () => {
@@ -115,6 +115,7 @@ describe('getUserAgent', () => {
describe('makeUserContextObject', () => {
let mockInitializeParams: InitializeParams
+ let mockServerInfo: ServerInfo
// let osStub: sinon.SinonStubbedInstance<{ now: () => number }>
beforeEach(() => {
@@ -123,10 +124,10 @@ describe('makeUserContextObject', () => {
aws: {
clientInfo: {
name: 'test-custom-client-name',
- version: '1.2.3',
+ version: '1.0.0',
extension: {
name: 'AmazonQ-For-VSCode',
- version: '2.2.2',
+ version: '2.0.0',
},
clientId: 'test-client-id',
},
@@ -138,6 +139,11 @@ describe('makeUserContextObject', () => {
},
} as InitializeParams
+ mockServerInfo = {
+ name: 'foo',
+ version: '3.0.0',
+ }
+
sinon.stub(process, 'platform').value('win32')
})
@@ -146,33 +152,33 @@ describe('makeUserContextObject', () => {
})
it('should return a valid UserContext object', () => {
- const result = makeUserContextObject(mockInitializeParams, 'win32', 'TestProduct')
+ const result = makeUserContextObject(mockInitializeParams, 'win32', 'TestProduct', mockServerInfo)
assert(result)
assert.ok('ideCategory' in result)
assert.ok('operatingSystem' in result)
assert.strictEqual(result.operatingSystem, 'WINDOWS')
assert.strictEqual(result.product, 'TestProduct')
assert.strictEqual(result.clientId, 'test-client-id')
- assert.strictEqual(result.ideVersion, '1.2.3')
+ assert.strictEqual(result.ideVersion, 'ide=1.0.0;plugin=2.0.0;lsp=3.0.0')
})
it('should prefer initializationOptions.aws version over clientInfo version', () => {
- const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct')
- assert.strictEqual(result?.ideVersion, '1.2.3')
+ const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct', mockServerInfo)
+ assert.strictEqual(result?.ideVersion, 'ide=1.0.0;plugin=2.0.0;lsp=3.0.0')
})
it('should use clientInfo version if initializationOptions.aws version is not available', () => {
// @ts-ignore
mockInitializeParams.initializationOptions.aws.clientInfo.version = undefined
- const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct')
- assert.strictEqual(result?.ideVersion, '1.1.1')
+ const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct', mockServerInfo)
+ assert.strictEqual(result?.ideVersion, 'ide=1.1.1;plugin=2.0.0;lsp=3.0.0')
})
it('should return undefined if ideCategory is not in IDE_CATEGORY_MAP', () => {
// @ts-ignore
mockInitializeParams.initializationOptions.aws.clientInfo.extension.name = 'Unknown IDE'
- const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct')
+ const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct', mockServerInfo)
assert.strictEqual(result, undefined)
})
@@ -188,7 +194,7 @@ describe('makeUserContextObject', () => {
// @ts-ignore
mockInitializeParams.initializationOptions.aws.clientInfo.extension.name = clientName
- const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct')
+ const result = makeUserContextObject(mockInitializeParams, 'linux', 'TestProduct', mockServerInfo)
switch (clientName) {
case 'AmazonQ-For-VSCode':
assert.strictEqual(result?.ideCategory, 'VSCODE')
@@ -222,7 +228,7 @@ describe('makeUserContextObject', () => {
]
platforms.forEach(platform => {
- const result = makeUserContextObject(mockInitializeParams, platform, 'TestProduct')
+ const result = makeUserContextObject(mockInitializeParams, platform, 'TestProduct', mockServerInfo)
switch (platform) {
case 'win32':
assert.strictEqual(result?.operatingSystem, 'WINDOWS')
diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.ts b/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.ts
index 73e94d4233..e682263f7e 100644
--- a/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/telemetryUtils.ts
@@ -89,15 +89,22 @@ const getOperatingSystem = (platform: Platform) => {
export const makeUserContextObject = (
initializeParams: InitializeParams,
platform: Platform,
- product: string
+ product: string,
+ serverInfo: ServerInfo
): UserContext | undefined => {
+ const ide = getIdeCategory(initializeParams)
+ const ideVersion =
+ initializeParams.initializationOptions?.aws?.clientInfo?.version || initializeParams.clientInfo?.version
+ const pluginVersion = initializeParams.initializationOptions?.aws?.clientInfo?.extension?.version || ''
+ const lspVersion = serverInfo.version ?? ''
const userContext: UserContext = {
- ideCategory: getIdeCategory(initializeParams),
+ ideCategory: ide,
operatingSystem: getOperatingSystem(platform),
product: product,
clientId: initializeParams.initializationOptions?.aws?.clientInfo?.clientId,
- ideVersion:
- initializeParams.initializationOptions?.aws?.clientInfo?.version || initializeParams.clientInfo?.version,
+ ideVersion: `ide=${ideVersion};plugin=${pluginVersion};lsp=${lspVersion}`,
+ pluginVersion: pluginVersion,
+ lspVersion: lspVersion,
}
if (userContext.ideCategory === 'UNKNOWN' || userContext.operatingSystem === 'UNKNOWN') {
diff --git a/server/aws-lsp-codewhisperer/src/shared/utils.test.ts b/server/aws-lsp-codewhisperer/src/shared/utils.test.ts
index 85cd8817a7..7ffe837938 100644
--- a/server/aws-lsp-codewhisperer/src/shared/utils.test.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/utils.test.ts
@@ -27,6 +27,7 @@ import {
getClientName,
sanitizeInput,
sanitizeRequestInput,
+ isUsingIAMAuth,
} from './utils'
import { promises as fsPromises } from 'fs'
@@ -185,6 +186,82 @@ describe('getOriginFromClientInfo', () => {
})
})
+describe('isUsingIAMAuth', () => {
+ let originalEnv: string | undefined
+
+ beforeEach(() => {
+ originalEnv = process.env.USE_IAM_AUTH
+ delete process.env.USE_IAM_AUTH
+ })
+
+ afterEach(() => {
+ if (originalEnv !== undefined) {
+ process.env.USE_IAM_AUTH = originalEnv
+ } else {
+ delete process.env.USE_IAM_AUTH
+ }
+ })
+
+ it('should return true when USE_IAM_AUTH environment variable is "true"', () => {
+ process.env.USE_IAM_AUTH = 'true'
+ assert.strictEqual(isUsingIAMAuth(), true)
+ })
+
+ it('should return false when USE_IAM_AUTH environment variable is not set', () => {
+ assert.strictEqual(isUsingIAMAuth(), false)
+ })
+
+ it('should return true when only IAM credentials are available', () => {
+ const mockProvider: CredentialsProvider = {
+ hasCredentials: sinon.stub().returns(true),
+ getCredentials: sinon
+ .stub()
+ .withArgs('iam')
+ .returns({ accessKeyId: 'AKIA...', secretAccessKey: 'secret' })
+ .withArgs('bearer')
+ .returns(null),
+ getConnectionMetadata: sinon.stub(),
+ getConnectionType: sinon.stub(),
+ onCredentialsDeleted: sinon.stub(),
+ }
+
+ assert.strictEqual(isUsingIAMAuth(mockProvider), true)
+ })
+
+ it('should return false when bearer credentials are available', () => {
+ const mockProvider: CredentialsProvider = {
+ hasCredentials: sinon.stub().returns(true),
+ getCredentials: sinon
+ .stub()
+ .withArgs('iam')
+ .returns({ accessKeyId: 'AKIA...', secretAccessKey: 'secret' })
+ .withArgs('bearer')
+ .returns({ token: 'bearer-token' }),
+ getConnectionMetadata: sinon.stub(),
+ getConnectionType: sinon.stub(),
+ onCredentialsDeleted: sinon.stub(),
+ }
+
+ assert.strictEqual(isUsingIAMAuth(mockProvider), false)
+ })
+
+ it('should return false when credential access fails', () => {
+ const mockProvider: CredentialsProvider = {
+ hasCredentials: sinon.stub().returns(true),
+ getCredentials: sinon.stub().throws(new Error('Access failed')),
+ getConnectionMetadata: sinon.stub(),
+ getConnectionType: sinon.stub(),
+ onCredentialsDeleted: sinon.stub(),
+ }
+
+ assert.strictEqual(isUsingIAMAuth(mockProvider), false)
+ })
+
+ it('should return false when credentialsProvider is undefined', () => {
+ assert.strictEqual(isUsingIAMAuth(undefined), false)
+ })
+})
+
describe('getSsoConnectionType', () => {
const mockToken = 'mockToken'
const mockCredsProvider: CredentialsProvider = {
diff --git a/server/aws-lsp-codewhisperer/src/shared/utils.ts b/server/aws-lsp-codewhisperer/src/shared/utils.ts
index a7a95d8801..af5cd14c28 100644
--- a/server/aws-lsp-codewhisperer/src/shared/utils.ts
+++ b/server/aws-lsp-codewhisperer/src/shared/utils.ts
@@ -392,8 +392,27 @@ export function getOriginFromClientInfo(clientName: string | undefined): Origin
return 'IDE'
}
-export function isUsingIAMAuth(): boolean {
- return process.env.USE_IAM_AUTH === 'true'
+export function isUsingIAMAuth(credentialsProvider?: CredentialsProvider): boolean {
+ if (process.env.USE_IAM_AUTH === 'true') {
+ return true
+ }
+
+ // CRITICAL: Add credential-based detection as fallback
+ if (credentialsProvider) {
+ try {
+ const iamCreds = credentialsProvider.getCredentials('iam')
+ const bearerCreds = credentialsProvider.getCredentials('bearer')
+
+ // If only IAM creds available, use IAM
+ if (iamCreds && !(bearerCreds as any)?.token) {
+ return true
+ }
+ } catch (error) {
+ // If credential access fails, default to bearer
+ return false
+ }
+ }
+ return false
}
export const flattenMetric = (obj: any, prefix = '') => {
diff --git a/server/aws-lsp-codewhisperer/src/types/unescape.d.ts b/server/aws-lsp-codewhisperer/src/types/unescape.d.ts
new file mode 100644
index 0000000000..1fe801704e
--- /dev/null
+++ b/server/aws-lsp-codewhisperer/src/types/unescape.d.ts
@@ -0,0 +1,4 @@
+declare module 'unescape-html' {
+ function unescapeHTML(str: string): string
+ export = unescapeHTML
+}
diff --git a/server/aws-lsp-identity/package.json b/server/aws-lsp-identity/package.json
index e8cb3b2c8a..3d90af77e4 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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-core": "^0.0.12",
"@smithy/node-http-handler": "^3.2.5",
"@smithy/shared-ini-file-loader": "^4.0.1",
diff --git a/server/aws-lsp-json/CHANGELOG.md b/server/aws-lsp-json/CHANGELOG.md
index 31c977b978..de134b8f6e 100644
--- a/server/aws-lsp-json/CHANGELOG.md
+++ b/server/aws-lsp-json/CHANGELOG.md
@@ -1,5 +1,33 @@
# Changelog
+## [0.1.19](https://github.com/aws/language-servers/compare/lsp-json/v0.1.18...lsp-json/v0.1.19) (2025-09-09)
+
+
+### Features
+
+* add support for getSupplementalContext LSP API ([#2212](https://github.com/aws/language-servers/issues/2212)) ([2ddcae7](https://github.com/aws/language-servers/commit/2ddcae7a4fac6b89cbc9784911959743ea0a6d11))
+
+
+### Dependencies
+
+* The following workspace dependencies were updated
+ * dependencies
+ * @aws/lsp-core bumped from ^0.0.14 to ^0.0.15
+
+## [0.1.18](https://github.com/aws/language-servers/compare/lsp-json/v0.1.17...lsp-json/v0.1.18) (2025-08-19)
+
+
+### Bug Fixes
+
+* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289))
+
+
+### Dependencies
+
+* The following workspace dependencies were updated
+ * dependencies
+ * @aws/lsp-core bumped from ^0.0.13 to ^0.0.14
+
## [0.1.17](https://github.com/aws/language-servers/compare/lsp-json/v0.1.16...lsp-json/v0.1.17) (2025-08-04)
diff --git a/server/aws-lsp-json/package.json b/server/aws-lsp-json/package.json
index 298f3820f0..b1ce196af0 100644
--- a/server/aws-lsp-json/package.json
+++ b/server/aws-lsp-json/package.json
@@ -1,6 +1,6 @@
{
"name": "@aws/lsp-json",
- "version": "0.1.17",
+ "version": "0.1.19",
"description": "JSON Language Server",
"main": "out/index.js",
"repository": {
@@ -26,8 +26,8 @@
"prepack": "shx cp ../../LICENSE ../../NOTICE ../../SECURITY.md ."
},
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
- "@aws/lsp-core": "^0.0.13",
+ "@aws/language-server-runtimes": "^0.2.128",
+ "@aws/lsp-core": "^0.0.15",
"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 a9e3cf9d4c..740a109f48 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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-core": "^0.0.12",
"vscode-languageserver": "^9.0.1"
},
diff --git a/server/aws-lsp-partiql/CHANGELOG.md b/server/aws-lsp-partiql/CHANGELOG.md
index b1be4c4050..37efbed366 100644
--- a/server/aws-lsp-partiql/CHANGELOG.md
+++ b/server/aws-lsp-partiql/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+## [0.0.18](https://github.com/aws/language-servers/compare/lsp-partiql/v0.0.17...lsp-partiql/v0.0.18) (2025-09-09)
+
+
+### Features
+
+* add support for getSupplementalContext LSP API ([#2212](https://github.com/aws/language-servers/issues/2212)) ([2ddcae7](https://github.com/aws/language-servers/commit/2ddcae7a4fac6b89cbc9784911959743ea0a6d11))
+
+## [0.0.17](https://github.com/aws/language-servers/compare/lsp-partiql/v0.0.16...lsp-partiql/v0.0.17) (2025-08-19)
+
+
+### Bug Fixes
+
+* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289))
+
## [0.0.16](https://github.com/aws/language-servers/compare/lsp-partiql/v0.0.15...lsp-partiql/v0.0.16) (2025-08-04)
diff --git a/server/aws-lsp-partiql/package.json b/server/aws-lsp-partiql/package.json
index 9ff685cfdc..2dc6c0e968 100644
--- a/server/aws-lsp-partiql/package.json
+++ b/server/aws-lsp-partiql/package.json
@@ -3,7 +3,7 @@
"author": "Amazon Web Services",
"license": "Apache-2.0",
"description": "PartiQL language server",
- "version": "0.0.16",
+ "version": "0.0.18",
"repository": {
"type": "git",
"url": "https://github.com/aws/language-servers"
@@ -24,7 +24,7 @@
"out"
],
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"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 caa7801b5f..8829f1dd87 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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"@aws/lsp-core": "^0.0.12",
"vscode-languageserver": "^9.0.1",
"vscode-languageserver-textdocument": "^1.0.8"
diff --git a/server/aws-lsp-yaml/CHANGELOG.md b/server/aws-lsp-yaml/CHANGELOG.md
index 965da41cfc..c43fc8f8f3 100644
--- a/server/aws-lsp-yaml/CHANGELOG.md
+++ b/server/aws-lsp-yaml/CHANGELOG.md
@@ -1,5 +1,33 @@
# Changelog
+## [0.1.19](https://github.com/aws/language-servers/compare/lsp-yaml/v0.1.18...lsp-yaml/v0.1.19) (2025-09-09)
+
+
+### Features
+
+* add support for getSupplementalContext LSP API ([#2212](https://github.com/aws/language-servers/issues/2212)) ([2ddcae7](https://github.com/aws/language-servers/commit/2ddcae7a4fac6b89cbc9784911959743ea0a6d11))
+
+
+### Dependencies
+
+* The following workspace dependencies were updated
+ * dependencies
+ * @aws/lsp-core bumped from ^0.0.14 to ^0.0.15
+
+## [0.1.18](https://github.com/aws/language-servers/compare/lsp-yaml/v0.1.17...lsp-yaml/v0.1.18) (2025-08-19)
+
+
+### Bug Fixes
+
+* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289))
+
+
+### Dependencies
+
+* The following workspace dependencies were updated
+ * dependencies
+ * @aws/lsp-core bumped from ^0.0.13 to ^0.0.14
+
## [0.1.17](https://github.com/aws/language-servers/compare/lsp-yaml/v0.1.16...lsp-yaml/v0.1.17) (2025-08-04)
diff --git a/server/aws-lsp-yaml/package.json b/server/aws-lsp-yaml/package.json
index 58fd5e591d..555b12c69a 100644
--- a/server/aws-lsp-yaml/package.json
+++ b/server/aws-lsp-yaml/package.json
@@ -1,6 +1,6 @@
{
"name": "@aws/lsp-yaml",
- "version": "0.1.17",
+ "version": "0.1.19",
"description": "YAML Language Server",
"main": "out/index.js",
"repository": {
@@ -26,8 +26,8 @@
"postinstall": "node patchYamlPackage.js"
},
"dependencies": {
- "@aws/language-server-runtimes": "^0.2.123",
- "@aws/lsp-core": "^0.0.13",
+ "@aws/language-server-runtimes": "^0.2.128",
+ "@aws/lsp-core": "^0.0.15",
"vscode-languageserver": "^9.0.1",
"vscode-languageserver-textdocument": "^1.0.8",
"yaml-language-server": "1.13.0"
diff --git a/server/device-sso-auth-lsp/package.json b/server/device-sso-auth-lsp/package.json
index 573170a3a9..9bca2bf0b7 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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"vscode-languageserver": "^9.0.1"
},
"devDependencies": {
diff --git a/server/hello-world-lsp/package.json b/server/hello-world-lsp/package.json
index 827509803e..b5944a3223 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.123",
+ "@aws/language-server-runtimes": "^0.2.128",
"vscode-languageserver": "^9.0.1"
},
"devDependencies": {