diff --git a/.github/workflows/create-agent-standalone.yml b/.github/workflows/create-agent-standalone.yml index 2807a50aea..f50b5b43b0 100644 --- a/.github/workflows/create-agent-standalone.yml +++ b/.github/workflows/create-agent-standalone.yml @@ -3,10 +3,12 @@ name: Create agent-standalone bundles on: push: branches: [main, feature/*, release/agentic/*] + workflow_dispatch: jobs: build: runs-on: ubuntu-latest + if: github.event_name == 'push' || github.actor_id == github.repository_owner_id steps: - name: Checkout repository diff --git a/.github/workflows/create-release-candidate-branch.yml b/.github/workflows/create-release-candidate-branch.yml index aa37873d2a..b447f11803 100644 --- a/.github/workflows/create-release-candidate-branch.yml +++ b/.github/workflows/create-release-candidate-branch.yml @@ -28,14 +28,15 @@ jobs: setupRcBranch: name: Set up a Release Candidate Branch runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Sync code uses: actions/checkout@v4 with: ref: ${{ inputs.commitId }} - # Use RELEASE_CANDIDATE_BRANCH_CREATION_PAT to ensure workflow triggering works - token: ${{ secrets.RELEASE_CANDIDATE_BRANCH_CREATION_PAT }} + token: ${{ secrets.GITHUB_TOKEN }} persist-credentials: true - name: Setup Node.js @@ -44,6 +45,10 @@ jobs: node-version: '20' cache: 'npm' + # Needed to format the json file being checked in + - name: Install dependencies + run: npm ci + - name: Calculate Release Version id: release-version env: @@ -87,6 +92,9 @@ jobs: git add "$VERSION_FILE" + # Ensure the file does not cause issues when merged to main + npm run format-staged + - name: Create Release Candidate Branch id: release-branch env: @@ -102,15 +110,8 @@ jobs: env: BRANCH_NAME: ${{ steps.release-branch.outputs.BRANCH_NAME }} RELEASE_VERSION: ${{ steps.release-version.outputs.RELEASE_VERSION }} - # We use the toolkit-automation account, basically something that - # isn't the default GitHub Token, because you cannot chain actions with that. - # In our case, after pushing a commit (below), we want create-agent-standalone.yml - # to start automatically. - REPO_PAT: ${{ secrets.RELEASE_CANDIDATE_BRANCH_CREATION_PAT }} run: | git config --global user.email "<>" git config --global user.name "aws-toolkit-automation" - # Configure git to use the PAT token for authentication - git remote set-url origin "https://x-access-token:${REPO_PAT}@github.com/${{ github.repository }}.git" - git commit -m "Bump agentic version: $RELEASE_VERSION" + git commit --no-verify -m "chore: bump agentic version: $RELEASE_VERSION" git push --set-upstream origin "$BRANCH_NAME" diff --git a/.github/workflows/lsp-ci.yaml b/.github/workflows/lsp-ci.yaml index bda44ec74a..15f87d2428 100644 --- a/.github/workflows/lsp-ci.yaml +++ b/.github/workflows/lsp-ci.yaml @@ -1,9 +1,9 @@ name: Language Server CI on: push: - branches: [main, dev, feature/*] + branches: [main, dev, feature/*, release/agentic/*] pull_request: - branches: [main, dev, feature/*] + branches: [main, dev, feature/*, release/agentic/*] jobs: test: diff --git a/.github/workflows/npm-packaging.yaml b/.github/workflows/npm-packaging.yaml index 724c9a0c05..d4ce77e07c 100644 --- a/.github/workflows/npm-packaging.yaml +++ b/.github/workflows/npm-packaging.yaml @@ -1,9 +1,9 @@ name: NPM Packaging on: push: - branches: [main, dev, feature/*] + branches: [main, dev, feature/*, release/agentic/*] pull_request: - branches: [main, dev, feature/*] + branches: [main, dev, feature/*, release/agentic/*] jobs: build: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 61dc88f5e1..49f118f219 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,9 +1,9 @@ { - "chat-client": "0.1.30", - "core/aws-lsp-core": "0.0.13", - "server/aws-lsp-antlr4": "0.1.17", - "server/aws-lsp-codewhisperer": "0.0.71", - "server/aws-lsp-json": "0.1.17", - "server/aws-lsp-partiql": "0.0.16", - "server/aws-lsp-yaml": "0.1.17" + "chat-client": "0.1.33", + "core/aws-lsp-core": "0.0.14", + "server/aws-lsp-antlr4": "0.1.18", + "server/aws-lsp-codewhisperer": "0.0.75", + "server/aws-lsp-json": "0.1.18", + "server/aws-lsp-partiql": "0.0.17", + "server/aws-lsp-yaml": "0.1.18" } diff --git a/app/aws-lsp-antlr4-runtimes/package.json b/app/aws-lsp-antlr4-runtimes/package.json index e283033afb..89fa95dcb3 100644 --- a/app/aws-lsp-antlr4-runtimes/package.json +++ b/app/aws-lsp-antlr4-runtimes/package.json @@ -12,7 +12,7 @@ "webpack": "webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-antlr4": "*", "antlr4-c3": "^3.4.1", "antlr4ng": "^3.0.4" diff --git a/app/aws-lsp-buildspec-runtimes/package.json b/app/aws-lsp-buildspec-runtimes/package.json index 0fa2217def..0ad07ddb8f 100644 --- a/app/aws-lsp-buildspec-runtimes/package.json +++ b/app/aws-lsp-buildspec-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-buildspec": "^0.0.1" } } diff --git a/app/aws-lsp-cloudformation-runtimes/package.json b/app/aws-lsp-cloudformation-runtimes/package.json index 336e9b48ae..d211149d0d 100644 --- a/app/aws-lsp-cloudformation-runtimes/package.json +++ b/app/aws-lsp-cloudformation-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-cloudformation": "^0.0.1" } } diff --git a/app/aws-lsp-codewhisperer-runtimes/README.md b/app/aws-lsp-codewhisperer-runtimes/README.md index 977d514bd6..71fbcf0456 100644 --- a/app/aws-lsp-codewhisperer-runtimes/README.md +++ b/app/aws-lsp-codewhisperer-runtimes/README.md @@ -76,6 +76,17 @@ The server is managed via scripts/dev-server.js, which ensures: **NOTE**: Tests are currently disabled for Windows as we currently face issues with automatically shutting down devserver and cleaning resources after tests are executed. +## Binary Dependencies + +### registry.node +The file `_bundle-assets/registry-js/win32-x64/registry.node` is a precompiled binary downloaded from the [registry-js](https://github.com/desktop/registry-js) project. + +- **Current version**: v1.16.1 (released May 21, 2024) +- **Source**: https://github.com/desktop/registry-js/releases +- **Purpose**: Provides Windows registry access functionality + +**To update**: Download the latest `registry.node` binary for win32-x64 from the registry-js releases page and replace the existing file. + #### Tests configuration - Test settings are defined in `wdio.conf.ts` - The actual test implementation is in the `test/e2e` folder diff --git a/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/registry-js/win32-x64/registry.node b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/registry-js/win32-x64/registry.node new file mode 100644 index 0000000000..d7b5091a2c Binary files /dev/null and b/app/aws-lsp-codewhisperer-runtimes/_bundle-assets/registry-js/win32-x64/registry.node differ diff --git a/app/aws-lsp-codewhisperer-runtimes/package.json b/app/aws-lsp-codewhisperer-runtimes/package.json index 6a1721382c..b890c01a78 100644 --- a/app/aws-lsp-codewhisperer-runtimes/package.json +++ b/app/aws-lsp-codewhisperer-runtimes/package.json @@ -10,10 +10,11 @@ "package:prod": "npm run compile && cross-env NODE_OPTIONS=--max_old_space_size=8172 npm run webpack:prod", "webpack": "webpack", "webpack:prod": "webpack --config webpack.config.prod.js", + "copy:native-deps:agent-standalone": "copyfiles -f _bundle-assets/registry-js/win32-x64/registry.node build/private/bundle/agent-standalone", "copy:resources:agent-standalone": "copyfiles -f --error ../../node_modules/@aws/lsp-identity/src/sso/authorizationCodePkce/resources/**/* build/private/bundle/agent-standalone/resources", "generate:node-assets": "./scripts/download-node.sh && ts-node src/scripts/copy-node-assets.ts", "generate:build-archive": "./scripts/package.sh", - "ci:generate:agent-standalone": "npm run package:prod && npm run copy:resources:agent-standalone && npm run generate:node-assets && npm run generate:build-archive", + "ci:generate:agent-standalone": "npm run package:prod && npm run copy:native-deps:agent-standalone && npm run copy:resources:agent-standalone && npm run generate:node-assets && npm run generate:build-archive", "ci:generate:manifest": "ts-node scripts/create-repo-manifest.ts", "start": "cross-env NODE_OPTIONS=--max_old_space_size=8172 node scripts/dev-server.js start", "stop-dev-server": "node scripts/dev-server.js stop", @@ -22,7 +23,7 @@ "local-build": "node scripts/local-build.js" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-codewhisperer": "*", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", diff --git a/app/aws-lsp-codewhisperer-runtimes/src/version.json b/app/aws-lsp-codewhisperer-runtimes/src/version.json index 3f5ef793d0..93401eb637 100644 --- a/app/aws-lsp-codewhisperer-runtimes/src/version.json +++ b/app/aws-lsp-codewhisperer-runtimes/src/version.json @@ -1,3 +1,3 @@ { - "agenticChat": "1.25.0" + "agenticChat": "1.29.0" } diff --git a/app/aws-lsp-identity-runtimes/package.json b/app/aws-lsp-identity-runtimes/package.json index b4971eae87..f33fa80da4 100644 --- a/app/aws-lsp-identity-runtimes/package.json +++ b/app/aws-lsp-identity-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-identity": "^0.0.1" } } diff --git a/app/aws-lsp-json-runtimes/package.json b/app/aws-lsp-json-runtimes/package.json index 9fa7b7e958..db1949f54b 100644 --- a/app/aws-lsp-json-runtimes/package.json +++ b/app/aws-lsp-json-runtimes/package.json @@ -11,7 +11,7 @@ "webpack": "webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-json": "*" }, "devDependencies": { diff --git a/app/aws-lsp-notification-runtimes/package.json b/app/aws-lsp-notification-runtimes/package.json index a355a967bf..a5eeecae49 100644 --- a/app/aws-lsp-notification-runtimes/package.json +++ b/app/aws-lsp-notification-runtimes/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-notification": "^0.0.1" } } diff --git a/app/aws-lsp-partiql-runtimes/package.json b/app/aws-lsp-partiql-runtimes/package.json index 0d5e07cddf..d483f3d0ce 100644 --- a/app/aws-lsp-partiql-runtimes/package.json +++ b/app/aws-lsp-partiql-runtimes/package.json @@ -11,7 +11,7 @@ "package": "npm run compile && npm run compile:webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.120", + "@aws/language-server-runtimes": "^0.2.125", "@aws/lsp-partiql": "^0.0.5" }, "devDependencies": { diff --git a/app/aws-lsp-s3-runtimes/package.json b/app/aws-lsp-s3-runtimes/package.json index 1c15a9af5e..9feb4f7ddc 100644 --- a/app/aws-lsp-s3-runtimes/package.json +++ b/app/aws-lsp-s3-runtimes/package.json @@ -10,7 +10,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-s3": "^0.0.1" } } diff --git a/app/aws-lsp-yaml-json-webworker/package.json b/app/aws-lsp-yaml-json-webworker/package.json index bfb5fa6277..fc524cabdc 100644 --- a/app/aws-lsp-yaml-json-webworker/package.json +++ b/app/aws-lsp-yaml-json-webworker/package.json @@ -11,7 +11,7 @@ "serve:webpack": "NODE_ENV=development webpack serve" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*" }, diff --git a/app/aws-lsp-yaml-runtimes/package.json b/app/aws-lsp-yaml-runtimes/package.json index 86638b8ffc..09bb93ee9a 100644 --- a/app/aws-lsp-yaml-runtimes/package.json +++ b/app/aws-lsp-yaml-runtimes/package.json @@ -11,7 +11,7 @@ "webpack": "webpack" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-yaml": "*" }, "devDependencies": { diff --git a/app/hello-world-lsp-runtimes/package.json b/app/hello-world-lsp-runtimes/package.json index 2ccccc694b..bff976d9b3 100644 --- a/app/hello-world-lsp-runtimes/package.json +++ b/app/hello-world-lsp-runtimes/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@aws/hello-world-lsp": "^0.0.1", - "@aws/language-server-runtimes": "^0.2.121" + "@aws/language-server-runtimes": "^0.2.127" }, "devDependencies": { "@types/chai": "^4.3.5", diff --git a/chat-client/CHANGELOG.md b/chat-client/CHANGELOG.md index 41178f1cb7..995ddc8ae6 100644 --- a/chat-client/CHANGELOG.md +++ b/chat-client/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## [0.1.33](https://github.com/aws/language-servers/compare/chat-client/v0.1.32...chat-client/v0.1.33) (2025-08-19) + + +### Features + +* **amazonq:** added mcp admin level configuration with GetProfile ([#2000](https://github.com/aws/language-servers/issues/2000)) ([fd6e9a8](https://github.com/aws/language-servers/commit/fd6e9a829c6229c276de5340dffce52b426a864d)) +* **amazonq:** read tool ui revamp ([#2113](https://github.com/aws/language-servers/issues/2113)) ([#2121](https://github.com/aws/language-servers/issues/2121)) ([93cf229](https://github.com/aws/language-servers/commit/93cf229149ba60491f9f5763793db4a9f570b611)) + + +### Bug Fixes + +* fix for button text and remove profilearn caching ([#2137](https://github.com/aws/language-servers/issues/2137)) ([2a4171a](https://github.com/aws/language-servers/commit/2a4171a74c15c23c23c481060496162bcc9e6284)) +* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289)) + +## [0.1.32](https://github.com/aws/language-servers/compare/chat-client/v0.1.31...chat-client/v0.1.32) (2025-08-11) + + +### Features + +* **amazonq:** read tool ui revamp ([c65428b](https://github.com/aws/language-servers/commit/c65428bab2cf5e47badf1e3a9453babcf881e60c)) + +## [0.1.31](https://github.com/aws/language-servers/compare/chat-client/v0.1.30...chat-client/v0.1.31) (2025-08-06) + + +### Features + +* **amazonq:** add two more tips for the did you know section ([#2063](https://github.com/aws/language-servers/issues/2063)) ([9949c6d](https://github.com/aws/language-servers/commit/9949c6dd81c56b5ea82563310da2eaee4d00a059)) +* **amazonq:** enable sonnet 4 for fra region ([#2069](https://github.com/aws/language-servers/issues/2069)) ([3a4b8df](https://github.com/aws/language-servers/commit/3a4b8df981b2c3b0532360a11472169fffec7924)) + + +### Bug Fixes + +* **amazonq:** fix to add disable/enable feature back to mcp servers ([#2052](https://github.com/aws/language-servers/issues/2052)) ([c03e017](https://github.com/aws/language-servers/commit/c03e017b9ccbbbb9c80a3c3afd5da38a50bd6cff)) + ## [0.1.30](https://github.com/aws/language-servers/compare/chat-client/v0.1.29...chat-client/v0.1.30) (2025-08-04) diff --git a/chat-client/package.json b/chat-client/package.json index 9eb33a6a11..e4250d42a1 100644 --- a/chat-client/package.json +++ b/chat-client/package.json @@ -1,6 +1,6 @@ { "name": "@aws/chat-client", - "version": "0.1.30", + "version": "0.1.33", "description": "AWS Chat Client", "main": "out/index.js", "repository": { @@ -25,9 +25,9 @@ }, "dependencies": { "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/language-server-runtimes-types": "^0.1.50", - "@aws/mynah-ui": "^4.36.2" + "@aws/mynah-ui": "^4.36.5" }, "devDependencies": { "@types/jsdom": "^21.1.6", diff --git a/chat-client/src/client/chat.ts b/chat-client/src/client/chat.ts index d090a33f70..58519d96ef 100644 --- a/chat-client/src/client/chat.ts +++ b/chat-client/src/client/chat.ts @@ -116,7 +116,6 @@ import { InboundChatApi, createMynahUi } from './mynahUi' import { TabFactory } from './tabs/tabFactory' import { ChatClientAdapter } from '../contracts/chatClientAdapter' import { toMynahContextCommand, toMynahIcon } from './utils' -import { modelSelectionForRegion } from './texts/modelSelection' const getDefaultTabConfig = (agenticMode?: boolean) => { return { @@ -264,20 +263,6 @@ export const createChat = ( return option }), }) - } else if (message.params.region) { - // TODO: This can be removed after all clients support aws/chat/listAvailableModels - // get all tabs and update region - const allExistingTabs: MynahUITabStoreModel = mynahUi.getAllTabs() - for (const tabId in allExistingTabs) { - const options = mynahUi.getTabData(tabId).getStore()?.promptInputOptions - mynahUi.updateStore(tabId, { - promptInputOptions: options?.map(option => - option.id === 'model-selection' - ? modelSelectionForRegion[message.params.region] - : option - ), - }) - } } else { tabFactory.setInfoMessages((message.params as ChatOptionsUpdateParams).chatNotifications) } diff --git a/chat-client/src/client/mcpMynahUi.test.ts b/chat-client/src/client/mcpMynahUi.test.ts index 3e1ca73f33..947e5bc604 100644 --- a/chat-client/src/client/mcpMynahUi.test.ts +++ b/chat-client/src/client/mcpMynahUi.test.ts @@ -107,10 +107,8 @@ describe('McpMynahUi', () => { assert.strictEqual(callArgs.detailedList.header.description, 'Test Description') assert.deepStrictEqual(callArgs.detailedList.header.status, { status: 'success' }) - // Verify the actions in the header - assert.strictEqual(callArgs.detailedList.header.actions.length, 2) - assert.strictEqual(callArgs.detailedList.header.actions[0].id, 'add-new-mcp') - assert.strictEqual(callArgs.detailedList.header.actions[1].id, 'refresh-mcp-list') + // Verify the actions in the header (no default actions are added when header is provided) + assert.strictEqual(callArgs.detailedList.header.actions.length, 0) // Verify the list structure assert.strictEqual(callArgs.detailedList.list.length, 1) @@ -577,8 +575,9 @@ describe('McpMynahUi', () => { assert.strictEqual(detailedList.header.actions[0].id, 'mcp-details-menu') // Verify the mcp-details-menu items - assert.strictEqual(detailedList.header.actions[0].items.length, 1) - assert.strictEqual(detailedList.header.actions[0].items[0].id, 'mcp-delete-server') + assert.strictEqual(detailedList.header.actions[0].items.length, 2) + assert.strictEqual(detailedList.header.actions[0].items[0].id, 'mcp-disable-server') + assert.strictEqual(detailedList.header.actions[0].items[1].id, 'mcp-delete-server') assert.strictEqual(detailedList.filterOptions.length, 1) assert.strictEqual(detailedList.filterOptions[0].id, 'permission') diff --git a/chat-client/src/client/mcpMynahUi.ts b/chat-client/src/client/mcpMynahUi.ts index 3e64336876..5cdae85da1 100644 --- a/chat-client/src/client/mcpMynahUi.ts +++ b/chat-client/src/client/mcpMynahUi.ts @@ -167,11 +167,11 @@ export class McpMynahUi { id: MCP_IDS.DETAILS_MENU, icon: toMynahIcon('ellipsis'), items: [ - // { - // id: MCP_IDS.DISABLE_SERVER, - // text: `Disable MCP server`, - // data: { serverName }, - // }, + { + id: MCP_IDS.DISABLE_SERVER, + text: `Disable MCP server`, + data: { serverName }, + }, { id: MCP_IDS.DELETE_SERVER, confirmation: { @@ -220,10 +220,10 @@ export class McpMynahUi { ...(action.id === MCP_IDS.DETAILS_MENU ? { items: [ - // { - // id: MCP_IDS.DISABLE_SERVER, - // text: `Disable MCP server`, - // }, + { + id: MCP_IDS.DISABLE_SERVER, + text: `Disable MCP server`, + }, { id: MCP_IDS.DELETE_SERVER, confirmation: { @@ -272,20 +272,12 @@ export class McpMynahUi { title: params.header.title, description: params.header.description, status: params.header.status, - actions: [ - { - id: MCP_IDS.ADD_NEW, - icon: toMynahIcon('plus'), - status: 'clear', - description: 'Add new MCP', - }, - { - id: MCP_IDS.REFRESH_LIST, - icon: toMynahIcon('refresh'), - status: 'clear', - description: 'Refresh MCP servers', - }, - ], + actions: + params.header.actions?.map(action => ({ + ...action, + icon: action.icon ? toMynahIcon(action.icon) : undefined, + text: undefined, + })) || [], } : undefined, filterOptions: params.filterOptions?.map(filter => ({ diff --git a/chat-client/src/client/mynahUi.test.ts b/chat-client/src/client/mynahUi.test.ts index 31e64f0e76..727805ebbc 100644 --- a/chat-client/src/client/mynahUi.test.ts +++ b/chat-client/src/client/mynahUi.test.ts @@ -260,7 +260,8 @@ describe('MynahUI', () => { sinon.assert.calledThrice(updateStoreSpy) }) - it('should create a new tab if current tab is loading', () => { + it('should create a new tab if current tab is loading', function (done) { + this.timeout(8000) // clear create tab stub since set up process calls it twice createTabStub.resetHistory() getAllTabsStub.returns({ 'tab-1': { store: { loadingChat: true } } }) @@ -274,6 +275,7 @@ describe('MynahUI', () => { sinon.assert.calledOnceWithExactly(createTabStub, false) sinon.assert.calledThrice(updateStoreSpy) + done() }) it('should not create a new tab if one exists already', () => { diff --git a/chat-client/src/client/mynahUi.ts b/chat-client/src/client/mynahUi.ts index 3b42249331..15cbeae7ff 100644 --- a/chat-client/src/client/mynahUi.ts +++ b/chat-client/src/client/mynahUi.ts @@ -151,11 +151,20 @@ export const handlePromptInputChange = (mynahUi: MynahUI, tabId: string, options } } + const updatedPromptInputOptions = promptInputOptions?.map(option => { + option.value = optionsValues[option.id] + return option + }) + mynahUi.updateStore(tabId, { - promptInputOptions: promptInputOptions?.map(option => { - option.value = optionsValues[option.id] - return option - }), + promptInputOptions: updatedPromptInputOptions, + }) + + // Store the updated values in tab defaults for new tabs + mynahUi.updateTabDefaults({ + store: { + promptInputOptions: updatedPromptInputOptions, + }, }) } @@ -414,6 +423,12 @@ export const createMynahUi = ( } const tabStore = mynahUi.getTabData(tabId).getStore() + const storedPromptInputOptions = mynahUi.getTabDefaults().store?.promptInputOptions + + // Retrieve stored model selection and pair programming mode from defaults + if (storedPromptInputOptions) { + defaultTabConfig.promptInputOptions = storedPromptInputOptions + } // Tabs can be opened through different methods, including server-initiated 'openTab' requests. // The 'openTab' request is specifically used for loading historical chat sessions with pre-existing messages. @@ -827,6 +842,7 @@ export const createMynahUi = ( // if we want to max user input as 500000, need to configure the maxUserInput as 500096 maxUserInput: 500096, userInputLengthWarningThreshold: 450000, + disableTypewriterAnimation: true, }, } @@ -1353,10 +1369,15 @@ export const createMynahUi = ( fileTreeTitle: '', hideFileCount: true, details: toDetailsWithoutIcon(header.fileList.details), + renderAsPills: + !header.fileList.details || + (Object.values(header.fileList.details).every(detail => !detail.changes) && + (!header.buttons || !header.buttons.some(button => button.id === 'undo-changes')) && + !header.status?.icon), } } if (!isPartialResult) { - if (processedHeader) { + if (processedHeader && !message.header?.status) { processedHeader.status = undefined } } @@ -1369,7 +1390,8 @@ export const createMynahUi = ( processedHeader.buttons !== null && processedHeader.buttons.length > 0) || processedHeader.status !== undefined || - processedHeader.icon !== undefined) + processedHeader.icon !== undefined || + processedHeader.fileList !== undefined) const padding = message.type === 'tool' ? (fileList ? true : message.messageId?.endsWith('_permission')) : undefined @@ -1380,8 +1402,10 @@ export const createMynahUi = ( // Adding this conditional check to show the stop message in the center. const contentHorizontalAlignment: ChatItem['contentHorizontalAlignment'] = undefined - // If message.header?.status?.text is Stopped or Rejected or Ignored or Completed etc.. card should be in disabled state. - const shouldMute = message.header?.status?.text !== undefined && message.header?.status?.text !== 'Completed' + // If message.header?.status?.text is Stopped or Rejected or Ignored etc.. card should be in disabled state. + const shouldMute = + message.header?.status?.text !== undefined && + ['Stopped', 'Rejected', 'Ignored', 'Failed', 'Error'].includes(message.header.status.text) return { body: message.body, diff --git a/chat-client/src/client/tabs/tabFactory.test.ts b/chat-client/src/client/tabs/tabFactory.test.ts index c398c709eb..815e81a22e 100644 --- a/chat-client/src/client/tabs/tabFactory.test.ts +++ b/chat-client/src/client/tabs/tabFactory.test.ts @@ -2,7 +2,7 @@ import { ChatHistory } from '../features/history' import { TabFactory } from './tabFactory' import * as assert from 'assert' import { pairProgrammingPromptInput } from '../texts/pairProgramming' -import { modelSelectionForRegion } from '../texts/modelSelection' +import { modelSelection } from '../texts/modelSelection' describe('tabFactory', () => { describe('getDefaultTabData', () => { @@ -92,10 +92,7 @@ describe('tabFactory', () => { const result = tabFactory.createTab(false) - assert.deepStrictEqual(result.promptInputOptions, [ - pairProgrammingPromptInput, - modelSelectionForRegion['us-east-1'], - ]) + assert.deepStrictEqual(result.promptInputOptions, [pairProgrammingPromptInput, modelSelection]) }) it('should not include model selection when only agentic mode is enabled', () => { diff --git a/chat-client/src/client/tabs/tabFactory.ts b/chat-client/src/client/tabs/tabFactory.ts index af1af21e7d..6df349896c 100644 --- a/chat-client/src/client/tabs/tabFactory.ts +++ b/chat-client/src/client/tabs/tabFactory.ts @@ -11,7 +11,7 @@ import { disclaimerCard } from '../texts/disclaimer' import { ChatMessage } from '@aws/language-server-runtimes-types' import { ChatHistory } from '../features/history' import { pairProgrammingPromptInput, programmerModeCard } from '../texts/pairProgramming' -import { modelSelectionForRegion } from '../texts/modelSelection' +import { modelSelection } from '../texts/modelSelection' export type DefaultTabData = MynahUIDataModel @@ -52,10 +52,7 @@ export class TabFactory { ...this.getDefaultTabData(), ...(disclaimerCardActive ? { promptInputStickyCard: disclaimerCard } : {}), promptInputOptions: this.agenticMode - ? [ - pairProgrammingPromptInput, - ...(this.modelSelectionEnabled ? [modelSelectionForRegion['us-east-1']] : []), - ] + ? [pairProgrammingPromptInput, ...(this.modelSelectionEnabled ? [modelSelection] : [])] : [], cancelButtonWhenLoading: this.agenticMode, // supported for agentic chat only } @@ -187,6 +184,8 @@ Select code & ask me to explain, debug or optimize it, or type \`/\` for quick a 'MCP is available in Amazon Q!', 'Pinned context is always included in future chat messages', 'Create and add Saved Prompts using the @ context menu', + 'Compact your conversation with /compact', + 'Ask Q to review your code and see results in the code issues panel!', ] const randomIndex = Math.floor(Math.random() * hints.length) diff --git a/chat-client/src/client/texts/modelSelection.test.ts b/chat-client/src/client/texts/modelSelection.test.ts index 762eb6f818..abd010436e 100644 --- a/chat-client/src/client/texts/modelSelection.test.ts +++ b/chat-client/src/client/texts/modelSelection.test.ts @@ -1,63 +1,11 @@ import * as assert from 'assert' -import { - BedrockModel, - modelSelectionForRegion, - getModelSelectionChatItem, - modelUnavailableBanner, - modelThrottledBanner, -} from './modelSelection' +import { getModelSelectionChatItem, modelUnavailableBanner, modelThrottledBanner } from './modelSelection' import { ChatItemType } from '@aws/mynah-ui' /** * Tests for modelSelection functionality - * - * Note: Some tests are for deprecated code (marked with 'legacy') that is maintained - * for backward compatibility with older clients. These should be removed once - * all clients have been updated to use the new API (aws/chat/listAvailableModels). */ describe('modelSelection', () => { - describe('BedrockModel enum (legacy)', () => { - it('should have the correct model IDs', () => { - assert.strictEqual(BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0, 'CLAUDE_3_7_SONNET_20250219_V1_0') - assert.strictEqual(BedrockModel.CLAUDE_SONNET_4_20250514_V1_0, 'CLAUDE_SONNET_4_20250514_V1_0') - }) - }) - - describe('modelSelectionForRegion (legacy)', () => { - it('should provide all models for us-east-1 region', () => { - const usEast1ModelSelection = modelSelectionForRegion['us-east-1'] - assert.ok(usEast1ModelSelection, 'usEast1ModelSelection should exist') - assert.ok(usEast1ModelSelection.type === 'select', 'usEast1ModelSelection should be type select') - assert.ok(Array.isArray(usEast1ModelSelection.options), 'options should be an array') - assert.strictEqual(usEast1ModelSelection.options.length, 2, 'should have 2 options') - - const modelIds = usEast1ModelSelection.options.map(option => option.value) - assert.ok(modelIds.includes(BedrockModel.CLAUDE_SONNET_4_20250514_V1_0), 'should include Claude Sonnet 4') - assert.ok( - modelIds.includes(BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0), - 'should include Claude Sonnet 3.7' - ) - }) - - it('should provide limited models for eu-central-1 region', () => { - const euCentral1ModelSelection = modelSelectionForRegion['eu-central-1'] - assert.ok(euCentral1ModelSelection, 'euCentral1ModelSelection should exist') - assert.ok(euCentral1ModelSelection.type === 'select', 'euCentral1ModelSelection should be type select') - assert.ok(Array.isArray(euCentral1ModelSelection.options), 'options should be an array') - assert.strictEqual(euCentral1ModelSelection.options.length, 1, 'should have 1 option') - - const modelIds = euCentral1ModelSelection.options.map(option => option.value) - assert.ok( - !modelIds.includes(BedrockModel.CLAUDE_SONNET_4_20250514_V1_0), - 'should not include Claude Sonnet 4' - ) - assert.ok( - modelIds.includes(BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0), - 'should include Claude Sonnet 3.7' - ) - }) - }) - describe('getModelSelectionChatItem', () => { it('should return a chat item with the correct model name', () => { const modelName = 'Claude Sonnet 4' diff --git a/chat-client/src/client/texts/modelSelection.ts b/chat-client/src/client/texts/modelSelection.ts index 28fe969bc9..a1d0247875 100644 --- a/chat-client/src/client/texts/modelSelection.ts +++ b/chat-client/src/client/texts/modelSelection.ts @@ -13,7 +13,7 @@ type ModelDetails = { } const modelRecord: Record = { - [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'Claude Sonnet 3.7' }, + [BedrockModel.CLAUDE_3_7_SONNET_20250219_V1_0]: { label: 'Claude 3.7 Sonnet' }, [BedrockModel.CLAUDE_SONNET_4_20250514_V1_0]: { label: 'Claude Sonnet 4' }, } @@ -22,27 +22,16 @@ const modelOptions = Object.entries(modelRecord).map(([value, { label }]) => ({ label, })) -const modelSelection: ChatItemFormItem = { +export const modelSelection: ChatItemFormItem = { type: 'select', id: 'model-selection', - options: modelOptions, mandatory: true, hideMandatoryIcon: true, + options: modelOptions, border: false, autoWidth: true, } -/** - * @deprecated use aws/chat/listAvailableModels server request instead - */ -export const modelSelectionForRegion: Record = { - 'us-east-1': modelSelection, - 'eu-central-1': { - ...modelSelection, - options: modelOptions.filter(option => option.value !== BedrockModel.CLAUDE_SONNET_4_20250514_V1_0), - }, -} - export const getModelSelectionChatItem = (modelName: string): ChatItem => ({ type: ChatItemType.DIRECTIVE, contentHorizontalAlignment: 'center', diff --git a/client/vscode/package.json b/client/vscode/package.json index 4b8c2fdb4c..a5d7f66d21 100644 --- a/client/vscode/package.json +++ b/client/vscode/package.json @@ -352,7 +352,7 @@ "@aws-sdk/credential-providers": "^3.731.1", "@aws-sdk/types": "^3.734.0", "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@types/uuid": "^9.0.8", "@types/vscode": "^1.98.0", "jose": "^5.2.4", diff --git a/core/aws-lsp-core/CHANGELOG.md b/core/aws-lsp-core/CHANGELOG.md index 9e2e616f55..0e1e63dd38 100644 --- a/core/aws-lsp-core/CHANGELOG.md +++ b/core/aws-lsp-core/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [0.0.14](https://github.com/aws/language-servers/compare/lsp-core/v0.0.13...lsp-core/v0.0.14) (2025-08-19) + + +### Features + +* **amazonq:** added mcp admin level configuration with GetProfile ([#2000](https://github.com/aws/language-servers/issues/2000)) ([fd6e9a8](https://github.com/aws/language-servers/commit/fd6e9a829c6229c276de5340dffce52b426a864d)) + + +### Bug Fixes + +* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289)) + ## [0.0.13](https://github.com/aws/language-servers/compare/lsp-core/v0.0.12...lsp-core/v0.0.13) (2025-08-04) diff --git a/core/aws-lsp-core/package.json b/core/aws-lsp-core/package.json index 4c033448eb..54ca980fc1 100644 --- a/core/aws-lsp-core/package.json +++ b/core/aws-lsp-core/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-core", - "version": "0.0.13", + "version": "0.0.14", "description": "Core library, contains common code and utilities", "main": "out/index.js", "repository": { @@ -28,7 +28,7 @@ "prepack": "shx cp ../../LICENSE ../../NOTICE ../../SECURITY.md ." }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@gerhobbelt/gitignore-parser": "^0.2.0-9", "cross-spawn": "7.0.6", "jose": "^5.2.4", diff --git a/core/aws-lsp-core/src/index.ts b/core/aws-lsp-core/src/index.ts index 1ad391dc8f..030a4fa2e3 100644 --- a/core/aws-lsp-core/src/index.ts +++ b/core/aws-lsp-core/src/index.ts @@ -19,3 +19,4 @@ export * as workspaceUtils from './util/workspaceUtils' export * as processUtils from './util/processUtils' export * as collectionUtils from './util/collectionUtils' export * as loggingUtils from './util/loggingUtils' +export * as retryUtils from './util/retryUtils' diff --git a/core/aws-lsp-core/src/util/retryUtils.test.ts b/core/aws-lsp-core/src/util/retryUtils.test.ts new file mode 100644 index 0000000000..4223e1f3bf --- /dev/null +++ b/core/aws-lsp-core/src/util/retryUtils.test.ts @@ -0,0 +1,120 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. + * All Rights Reserved. SPDX-License-Identifier: Apache-2.0 + */ + +import { expect } from 'chai' +import * as sinon from 'sinon' +import { retryWithBackoff, DEFAULT_MAX_RETRIES, DEFAULT_BASE_DELAY } from './retryUtils' + +describe('retryUtils', () => { + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + afterEach(() => { + clock.restore() + }) + + describe('retryWithBackoff', () => { + it('should return result on first success', async () => { + const fn = sinon.stub().resolves('success') + + const result = await retryWithBackoff(fn) + + expect(result).to.equal('success') + expect(fn.callCount).to.equal(1) + }) + + it('should retry on retryable errors', async () => { + const fn = sinon.stub() + fn.onFirstCall().rejects({ code: 'ThrottlingException' }) + fn.onSecondCall().resolves('success') + + const promise = retryWithBackoff(fn) + await clock.tickAsync(DEFAULT_BASE_DELAY) + const result = await promise + + expect(result).to.equal('success') + expect(fn.callCount).to.equal(2) + }) + + it('should not retry on non-retryable client errors', async () => { + const error = { statusCode: 404 } + const fn = sinon.stub().rejects(error) + + try { + await retryWithBackoff(fn) + expect.fail('Expected function to throw') + } catch (e) { + expect(e).to.equal(error) + } + expect(fn.callCount).to.equal(1) + }) + + it('should retry on server errors', async () => { + const fn = sinon.stub() + fn.onFirstCall().rejects({ statusCode: 500 }) + fn.onSecondCall().resolves('success') + + const promise = retryWithBackoff(fn) + await clock.tickAsync(DEFAULT_BASE_DELAY) + const result = await promise + + expect(result).to.equal('success') + expect(fn.callCount).to.equal(2) + }) + + it('should use exponential backoff by default', async () => { + const fn = sinon.stub() + const error = { code: 'ThrottlingException' } + fn.onFirstCall().rejects(error) + fn.onSecondCall().rejects(error) + + const promise = retryWithBackoff(fn) + + // First retry after baseDelay * 1 + await clock.tickAsync(DEFAULT_BASE_DELAY) + // Second retry after baseDelay * 2 + await clock.tickAsync(DEFAULT_BASE_DELAY * 2) + + try { + await promise + expect.fail('Expected function to throw') + } catch (e) { + expect(e).to.equal(error) + } + expect(fn.callCount).to.equal(DEFAULT_MAX_RETRIES) + }) + + it('should respect custom maxRetries', async () => { + const error = { code: 'ThrottlingException' } + const fn = sinon.stub().rejects(error) + + try { + await retryWithBackoff(fn, { maxRetries: 1 }) + expect.fail('Expected function to throw') + } catch (e) { + expect(e).to.equal(error) + } + expect(fn.callCount).to.equal(1) + }) + + it('should use custom isRetryable function', async () => { + const error = { custom: 'error' } + const fn = sinon.stub().rejects(error) + const isRetryable = sinon.stub().returns(false) + + try { + await retryWithBackoff(fn, { isRetryable }) + expect.fail('Expected function to throw') + } catch (e) { + expect(e).to.equal(error) + } + expect(fn.callCount).to.equal(1) + expect(isRetryable.calledWith(error)).to.equal(true) + }) + }) +}) diff --git a/core/aws-lsp-core/src/util/retryUtils.ts b/core/aws-lsp-core/src/util/retryUtils.ts new file mode 100644 index 0000000000..dc135ce23d --- /dev/null +++ b/core/aws-lsp-core/src/util/retryUtils.ts @@ -0,0 +1,77 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. + * All Rights Reserved. SPDX-License-Identifier: Apache-2.0 + */ + +// Default retry configuration constants +export const DEFAULT_MAX_RETRIES = 2 +export const DEFAULT_BASE_DELAY = 500 +export const DEFAULT_EXPONENTIAL_BACKOFF = true + +// HTTP status code constants +const CLIENT_ERROR_MIN = 400 +const CLIENT_ERROR_MAX = 500 +const INTERNAL_SERVER_ERROR = 500 +const SERVICE_UNAVAILABLE = 503 + +// AWS error code constants +const THROTTLING_EXCEPTION = 'ThrottlingException' +const INTERNAL_SERVER_EXCEPTION = 'InternalServerException' + +export interface RetryOptions { + /** Maximum number of retry attempts (default: DEFAULT_MAX_RETRIES) */ + maxRetries?: number + /** Base delay in milliseconds (default: DEFAULT_BASE_DELAY) */ + baseDelay?: number + /** Whether to use exponential backoff (default: DEFAULT_EXPONENTIAL_BACKOFF) */ + exponentialBackoff?: boolean + /** Custom function to determine if an error is retryable */ + isRetryable?: (error: any) => boolean +} + +/** + * Default AWS error retry logic + */ +function defaultIsRetryable(error: any): boolean { + const errorCode = error.code || error.name + const statusCode = error.statusCode + + // Fast fail on non-retryable client errors (except throttling) + if (statusCode >= CLIENT_ERROR_MIN && statusCode < CLIENT_ERROR_MAX && errorCode !== THROTTLING_EXCEPTION) { + return false + } + + // Retry on throttling, server errors, and specific status codes + return ( + errorCode === THROTTLING_EXCEPTION || + errorCode === INTERNAL_SERVER_EXCEPTION || + statusCode === INTERNAL_SERVER_ERROR || + statusCode === SERVICE_UNAVAILABLE + ) +} + +/** + * Executes a function with retry logic and exponential backoff + */ +export async function retryWithBackoff(fn: () => Promise, options: RetryOptions = {}): Promise { + const { + maxRetries = DEFAULT_MAX_RETRIES, + baseDelay = DEFAULT_BASE_DELAY, + exponentialBackoff = DEFAULT_EXPONENTIAL_BACKOFF, + isRetryable = defaultIsRetryable, + } = options + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn() + } catch (error: any) { + if (!isRetryable(error) || attempt === maxRetries - 1) { + throw error + } + + const delay = exponentialBackoff ? baseDelay * (attempt + 1) : baseDelay + await new Promise(resolve => setTimeout(resolve, delay)) + } + } + throw new Error('Retry failed') +} diff --git a/integration-tests/q-agentic-chat-server/package.json b/integration-tests/q-agentic-chat-server/package.json index 26202df0c1..ffb33f1a4e 100644 --- a/integration-tests/q-agentic-chat-server/package.json +++ b/integration-tests/q-agentic-chat-server/package.json @@ -9,7 +9,7 @@ "test-integ": "npm run compile && mocha --timeout 30000 \"./out/**/*.test.js\" --retries 2" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "*" }, "devDependencies": { diff --git a/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts b/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts index 897ee02071..c5a8046bd5 100644 --- a/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts +++ b/integration-tests/q-agentic-chat-server/src/tests/agenticChatInteg.test.ts @@ -169,11 +169,11 @@ describe('Q Agentic Chat Server Integration Tests', async () => { expect(decryptedResult.additionalMessages).to.be.an('array') const fsReadMessage = decryptedResult.additionalMessages?.find( - msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 file read' + msg => msg.type === 'tool' && msg.header?.body === '1 file read' ) expect(fsReadMessage).to.exist const expectedPath = path.join(rootPath, 'test.py') - const actualPaths = fsReadMessage?.fileList?.filePaths?.map(normalizePath) || [] + const actualPaths = fsReadMessage?.header?.fileList?.filePaths?.map(normalizePath) || [] expect(actualPaths).to.include.members([normalizePath(expectedPath)]) expect(fsReadMessage?.messageId?.startsWith('tooluse_')).to.be.true }) @@ -191,10 +191,10 @@ describe('Q Agentic Chat Server Integration Tests', async () => { expect(decryptedResult.additionalMessages).to.be.an('array') const listDirectoryMessage = decryptedResult.additionalMessages?.find( - msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 directory listed' + msg => msg.type === 'tool' && msg.header?.body === '1 directory listed' ) expect(listDirectoryMessage).to.exist - const actualPaths = listDirectoryMessage?.fileList?.filePaths?.map(normalizePath) || [] + const actualPaths = listDirectoryMessage?.header?.fileList?.filePaths?.map(normalizePath) || [] expect(actualPaths).to.include.members([normalizePath(rootPath)]) expect(listDirectoryMessage?.messageId?.startsWith('tooluse_')).to.be.true }) @@ -371,11 +371,12 @@ describe('Q Agentic Chat Server Integration Tests', async () => { expect(decryptedResult.additionalMessages).to.be.an('array') const fileSearchMessage = decryptedResult.additionalMessages?.find( - msg => msg.type === 'tool' && msg.fileList?.rootFolderTitle === '1 directory searched' + msg => msg.type === 'tool' && msg.header?.body === 'Searched for "test" in ' ) expect(fileSearchMessage).to.exist expect(fileSearchMessage?.messageId?.startsWith('tooluse_')).to.be.true - const actualPaths = fileSearchMessage?.fileList?.filePaths?.map(normalizePath) || [] + expect(fileSearchMessage?.header?.status?.text).to.equal('3 results found') + const actualPaths = fileSearchMessage?.header?.fileList?.filePaths?.map(normalizePath) || [] expect(actualPaths).to.include.members([normalizePath(rootPath)]) }) }) diff --git a/package-lock.json b/package-lock.json index 1d8f367c34..5fc601a1bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "name": "@aws/lsp-antlr4-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-antlr4": "*", "antlr4-c3": "^3.4.1", "antlr4ng": "^3.0.4" @@ -71,7 +71,7 @@ "name": "@aws/lsp-buildspec-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-buildspec": "^0.0.1" } }, @@ -79,7 +79,7 @@ "name": "@aws/lsp-cloudformation-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-cloudformation": "^0.0.1" } }, @@ -87,7 +87,7 @@ "name": "@aws/lsp-codewhisperer-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-codewhisperer": "*", "copyfiles": "^2.4.1", "cross-env": "^7.0.3", @@ -120,7 +120,7 @@ "name": "@aws/lsp-identity-runtimes", "version": "0.1.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-identity": "^0.0.1" } }, @@ -128,7 +128,7 @@ "name": "@aws/lsp-json-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-json": "*" }, "devDependencies": { @@ -148,7 +148,7 @@ "name": "@aws/lsp-notification-runtimes", "version": "0.1.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-notification": "^0.0.1" } }, @@ -156,7 +156,7 @@ "name": "@aws/lsp-partiql-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.120", + "@aws/language-server-runtimes": "^0.2.125", "@aws/lsp-partiql": "^0.0.5" }, "devDependencies": { @@ -181,7 +181,7 @@ "name": "@aws/lsp-s3-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-s3": "^0.0.1" }, "bin": { @@ -192,7 +192,7 @@ "name": "@aws/lsp-yaml-json-webworker", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*" }, @@ -212,7 +212,7 @@ "name": "@aws/lsp-yaml-runtimes", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-yaml": "*" }, "devDependencies": { @@ -234,7 +234,7 @@ "version": "0.0.1", "dependencies": { "@aws/hello-world-lsp": "^0.0.1", - "@aws/language-server-runtimes": "^0.2.121" + "@aws/language-server-runtimes": "^0.2.127" }, "devDependencies": { "@types/chai": "^4.3.5", @@ -251,13 +251,13 @@ }, "chat-client": { "name": "@aws/chat-client", - "version": "0.1.30", + "version": "0.1.33", "license": "Apache-2.0", "dependencies": { "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/language-server-runtimes-types": "^0.1.50", - "@aws/mynah-ui": "^4.36.2" + "@aws/mynah-ui": "^4.36.5" }, "devDependencies": { "@types/jsdom": "^21.1.6", @@ -280,7 +280,7 @@ "@aws-sdk/credential-providers": "^3.731.1", "@aws-sdk/types": "^3.734.0", "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@types/uuid": "^9.0.8", "@types/vscode": "^1.98.0", "jose": "^5.2.4", @@ -293,10 +293,10 @@ }, "core/aws-lsp-core": { "name": "@aws/lsp-core", - "version": "0.0.13", + "version": "0.0.14", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@gerhobbelt/gitignore-parser": "^0.2.0-9", "cross-spawn": "7.0.6", "jose": "^5.2.4", @@ -327,7 +327,7 @@ "name": "@aws/q-agentic-chat-server-integration-tests", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "*" }, "devDependencies": { @@ -4036,12 +4036,11 @@ "link": true }, "node_modules/@aws/language-server-runtimes": { - "version": "0.2.121", - "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.121.tgz", - "integrity": "sha512-DDh3ICNVoEi4nhp4JdkkPTsdlHsF0yt6VgxlMGwP90N1hjA4xVESSSla9pB0TcuMPKlmXqOPQGQvkph9FKerVw==", - "license": "Apache-2.0", + "version": "0.2.127", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes/-/language-server-runtimes-0.2.127.tgz", + "integrity": "sha512-UWCfv49MYaBhxArVBWTEw2XVfIyunbm6EfS9AxSLPudcwrpOg3KAVLooXearmyM/r2hgNDGCQYI8HuKf5FAnew==", "dependencies": { - "@aws/language-server-runtimes-types": "^0.1.53", + "@aws/language-server-runtimes-types": "^0.1.56", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.200.0", "@opentelemetry/core": "^2.0.0", @@ -4056,7 +4055,7 @@ "hpagent": "^1.2.0", "jose": "^5.9.6", "mac-ca": "^3.1.1", - "os-proxy-config": "^1.1.2", + "registry-js": "^1.16.1", "rxjs": "^7.8.2", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", @@ -4068,9 +4067,9 @@ } }, "node_modules/@aws/language-server-runtimes-types": { - "version": "0.1.53", - "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.1.53.tgz", - "integrity": "sha512-6KCe/YsqF0SciXm8qg/qVuDXGwQqJgRaqrT6YhZUjqs3mclG9G6Gdwu9YEi8t/NYobNWKw0E+aCXW2TFxJgr7A==", + "version": "0.1.56", + "resolved": "https://registry.npmjs.org/@aws/language-server-runtimes-types/-/language-server-runtimes-types-0.1.56.tgz", + "integrity": "sha512-Md/L750JShCHUsCQUJva51Ofkn/GDBEX8PpZnWUIVqkpddDR00SLQS2smNf4UHtKNJ2fefsfks/Kqfuatjkjvg==", "license": "Apache-2.0", "dependencies": { "vscode-languageserver-textdocument": "^1.0.12", @@ -4204,9 +4203,9 @@ "link": true }, "node_modules/@aws/mynah-ui": { - "version": "4.36.2", - "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.36.2.tgz", - "integrity": "sha512-3ibfK2CTj7dlFFdgTIE1DdEyDpy+P3hdP/Fmlx76T9GGSYiGHqwunDSi59L1P61Kj46WADBrQ52mLUQ6FR8Rzg==", + "version": "4.36.5", + "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.36.5.tgz", + "integrity": "sha512-HMXqvSpZT84mpY67ChzRDrd73Y9AFZVZ8RcOJ/rNWIXR44uryfNFg2nrvoP4GSn2P+kU8WIPGChHGmyX9N0UgA==", "hasInstallScript": true, "license": "Apache License 2.0", "dependencies": { @@ -20292,12 +20291,6 @@ "undici": "^6.16.1" } }, - "node_modules/mac-system-proxy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mac-system-proxy/-/mac-system-proxy-1.0.4.tgz", - "integrity": "sha512-IAkNLxXZrYuM99A2OhPrvUoAxohsxQciJh2D2xnD+R6vypn/AVyOYLsbZsMVCS/fEbLIe67nQ8krEAfqP12BVg==", - "license": "Apache-2.0" - }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -21387,16 +21380,6 @@ "node": ">=0.10.0" } }, - "node_modules/os-proxy-config": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/os-proxy-config/-/os-proxy-config-1.1.2.tgz", - "integrity": "sha512-sV7htE8y6NQORU0oKOUGTwQYe1gSFK3a3Z1i4h6YaqdrA9C0JIsUPQAqEkO8ejjYbRrQ+jsnks5qjtisr7042Q==", - "license": "Apache-2.0", - "dependencies": { - "mac-system-proxy": "^1.0.0", - "windows-system-proxy": "^1.0.0" - } - }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -28090,15 +28073,6 @@ "node": ">=4" } }, - "node_modules/windows-system-proxy": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/windows-system-proxy/-/windows-system-proxy-1.0.0.tgz", - "integrity": "sha512-qd1WfyX9gjAqI36RHt95di2+FBr74DhvELd1EASgklCGScjwReHnWnXfUyabp/CJWl/IdnkUzG0Ub6Cv2R4KJQ==", - "license": "Apache-2.0", - "dependencies": { - "registry-js": "^1.15.1" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -28630,11 +28604,11 @@ }, "server/aws-lsp-antlr4": { "name": "@aws/lsp-antlr4", - "version": "0.1.17", + "version": "0.1.18", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13" + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14" }, "devDependencies": { "@babel/plugin-transform-modules-commonjs": "^7.24.1", @@ -28675,7 +28649,7 @@ "name": "@aws/lsp-buildspec", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*", "vscode-languageserver": "^9.0.1", @@ -28686,7 +28660,7 @@ "name": "@aws/lsp-cloudformation", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "*", "@aws/lsp-json": "*", "vscode-languageserver": "^9.0.1", @@ -28695,7 +28669,7 @@ }, "server/aws-lsp-codewhisperer": { "name": "@aws/lsp-codewhisperer", - "version": "0.0.71", + "version": "0.0.75", "bundleDependencies": [ "@amzn/codewhisperer-streaming", "@amzn/amazon-q-developer-streaming-client" @@ -28708,8 +28682,8 @@ "@aws-sdk/util-arn-parser": "^3.723.0", "@aws-sdk/util-retry": "^3.374.0", "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13", + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14", "@modelcontextprotocol/sdk": "^1.15.0", "@smithy/node-http-handler": "^2.5.0", "adm-zip": "^0.5.10", @@ -28848,7 +28822,7 @@ "dependencies": { "@aws-sdk/client-sso-oidc": "^3.616.0", "@aws-sdk/token-providers": "^3.744.0", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "^0.0.12", "@smithy/node-http-handler": "^3.2.5", "@smithy/shared-ini-file-loader": "^4.0.1", @@ -28910,11 +28884,11 @@ }, "server/aws-lsp-json": { "name": "@aws/lsp-json", - "version": "0.1.17", + "version": "0.1.18", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13", + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8" }, @@ -28930,7 +28904,7 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "^0.0.12", "vscode-languageserver": "^9.0.1" }, @@ -28988,10 +28962,10 @@ }, "server/aws-lsp-partiql": { "name": "@aws/lsp-partiql", - "version": "0.0.16", + "version": "0.0.17", "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "antlr4-c3": "3.4.2", "antlr4ng": "3.0.14", "web-tree-sitter": "0.22.6" @@ -29013,7 +28987,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.623.0", "@aws-sdk/types": "^3.734.0", - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "^0.0.12", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8" @@ -29040,12 +29014,12 @@ }, "server/aws-lsp-yaml": { "name": "@aws/lsp-yaml", - "version": "0.1.17", + "version": "0.1.18", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13", + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.8", "yaml-language-server": "1.13.0" @@ -29058,7 +29032,7 @@ "name": "@amzn/device-sso-auth-lsp", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "vscode-languageserver": "^9.0.1" }, "devDependencies": { @@ -29069,7 +29043,7 @@ "name": "@aws/hello-world-lsp", "version": "0.0.1", "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "vscode-languageserver": "^9.0.1" }, "devDependencies": { diff --git a/server/aws-lsp-antlr4/CHANGELOG.md b/server/aws-lsp-antlr4/CHANGELOG.md index bde43f2c3c..e1458575e1 100644 --- a/server/aws-lsp-antlr4/CHANGELOG.md +++ b/server/aws-lsp-antlr4/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.1.18](https://github.com/aws/language-servers/compare/lsp-antlr4/v0.1.17...lsp-antlr4/v0.1.18) (2025-08-19) + + +### Bug Fixes + +* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @aws/lsp-core bumped from ^0.0.13 to ^0.0.14 + ## [0.1.17](https://github.com/aws/language-servers/compare/lsp-antlr4/v0.1.16...lsp-antlr4/v0.1.17) (2025-08-04) diff --git a/server/aws-lsp-antlr4/package.json b/server/aws-lsp-antlr4/package.json index aadfe48b08..2ca49593d7 100644 --- a/server/aws-lsp-antlr4/package.json +++ b/server/aws-lsp-antlr4/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-antlr4", - "version": "0.1.17", + "version": "0.1.18", "description": "ANTLR4 language server", "main": "out/index.js", "repository": { @@ -28,8 +28,8 @@ "clean": "rm -rf node_modules" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13" + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14" }, "peerDependencies": { "antlr4-c3": ">=3.4 < 4", diff --git a/server/aws-lsp-buildspec/package.json b/server/aws-lsp-buildspec/package.json index 92960545fb..9754645b7d 100644 --- a/server/aws-lsp-buildspec/package.json +++ b/server/aws-lsp-buildspec/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-json": "*", "@aws/lsp-yaml": "*", "vscode-languageserver": "^9.0.1", diff --git a/server/aws-lsp-cloudformation/package.json b/server/aws-lsp-cloudformation/package.json index 2dd24848b8..13be6a4859 100644 --- a/server/aws-lsp-cloudformation/package.json +++ b/server/aws-lsp-cloudformation/package.json @@ -7,7 +7,7 @@ "compile": "tsc --build" }, "dependencies": { - "@aws/language-server-runtimes": "^0.2.121", + "@aws/language-server-runtimes": "^0.2.127", "@aws/lsp-core": "*", "@aws/lsp-json": "*", "vscode-languageserver": "^9.0.1", diff --git a/server/aws-lsp-codewhisperer/CHANGELOG.md b/server/aws-lsp-codewhisperer/CHANGELOG.md index 4d89e37f70..a70284a4c2 100644 --- a/server/aws-lsp-codewhisperer/CHANGELOG.md +++ b/server/aws-lsp-codewhisperer/CHANGELOG.md @@ -1,5 +1,84 @@ # Changelog +## [0.0.75](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.74...lsp-codewhisperer/v0.0.75) (2025-08-21) + + +### Bug Fixes + +* **amazonq:** don't let flare send discard for the still valid suggestion in JB ([#2145](https://github.com/aws/language-servers/issues/2145)) ([0767e07](https://github.com/aws/language-servers/commit/0767e074c91682a91d2fe7a6b2a7369c4dea280c)) + +## [0.0.74](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.73...lsp-codewhisperer/v0.0.74) (2025-08-19) + + +### Features + +* **amazonq:** added mcp admin level configuration with GetProfile ([#2000](https://github.com/aws/language-servers/issues/2000)) ([fd6e9a8](https://github.com/aws/language-servers/commit/fd6e9a829c6229c276de5340dffce52b426a864d)) +* **amazonq:** read tool ui revamp ([#2113](https://github.com/aws/language-servers/issues/2113)) ([#2121](https://github.com/aws/language-servers/issues/2121)) ([93cf229](https://github.com/aws/language-servers/commit/93cf229149ba60491f9f5763793db4a9f570b611)) +* remove project type validation from LSP layer ([#2103](https://github.com/aws/language-servers/issues/2103)) ([d397161](https://github.com/aws/language-servers/commit/d397161cc3448c63016e27f5ac2a1917cdaae1cb)) + + +### Bug Fixes + +* **amazonq:** add server side control for WCS features ([#2128](https://github.com/aws/language-servers/issues/2128)) ([5e4435d](https://github.com/aws/language-servers/commit/5e4435dfaea7bf8c00e6a27b9bb0d40f699d4e01)) +* **amazonq:** fix regression of mcp config in agent config ([#2101](https://github.com/aws/language-servers/issues/2101)) ([e4e8bbb](https://github.com/aws/language-servers/commit/e4e8bbb89e4b597926582bead2b14ffc43f2a7f8)) +* **amazonq:** handle case where multiple rules are provided with the same name ([#2118](https://github.com/aws/language-servers/issues/2118)) ([0e23e2d](https://github.com/aws/language-servers/commit/0e23e2d29b8cad14403d372b9bbb08ca8ffa7ac7)) +* **amazonq:** persist mcp configs in agent json on start-up ([#2112](https://github.com/aws/language-servers/issues/2112)) ([817cfe2](https://github.com/aws/language-servers/commit/817cfe2656cb1deec6111c699c4ba46b4ba53e00)) +* empty userTriggerDecision not being sent for NEP code path ([#2140](https://github.com/aws/language-servers/issues/2140)) ([b8e5268](https://github.com/aws/language-servers/commit/b8e52682ac2b2337e1d0a32759e8beccde889cee)) +* fix for button text and remove profilearn caching ([#2137](https://github.com/aws/language-servers/issues/2137)) ([2a4171a](https://github.com/aws/language-servers/commit/2a4171a74c15c23c23c481060496162bcc9e6284)) +* fix to add disk caching for mcp admin state ([#2139](https://github.com/aws/language-servers/issues/2139)) ([f947e1a](https://github.com/aws/language-servers/commit/f947e1a9da4431d6089b22825f992010c30a470b)) +* fix to turn on and off MCP servers incase of error based on last state ([#2143](https://github.com/aws/language-servers/issues/2143)) ([04588df](https://github.com/aws/language-servers/commit/04588dfc33f0d85dbd488814a474b5e354398df0)) +* proper path handling for additional context ([#2129](https://github.com/aws/language-servers/issues/2129)) ([971eaa5](https://github.com/aws/language-servers/commit/971eaa505d948e9d2090c85f9b965f554ea7f2c8)) +* Use file context override in the inline completion params for Jupyter Notebook ([#2114](https://github.com/aws/language-servers/issues/2114)) ([91c8398](https://github.com/aws/language-servers/commit/91c839857f8aa4d79098189f9fb620b361c51289)) + + +### Performance Improvements + +* remove edit completion retry mechanism on document change ([#2124](https://github.com/aws/language-servers/issues/2124)) ([963b6e9](https://github.com/aws/language-servers/commit/963b6e9b7887da23a85a826c55a6ed95ff36d956)) + + +### Dependencies + +* The following workspace dependencies were updated + * dependencies + * @aws/lsp-core bumped from ^0.0.13 to ^0.0.14 + +## [0.0.73](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.72...lsp-codewhisperer/v0.0.73) (2025-08-11) + + +### Features + +* **amazonq:** read tool ui revamp ([c65428b](https://github.com/aws/language-servers/commit/c65428bab2cf5e47badf1e3a9453babcf881e60c)) + + +### Bug Fixes + +* **amazonq:** add fallback classpath generation ([#2077](https://github.com/aws/language-servers/issues/2077)) ([3a6ef14](https://github.com/aws/language-servers/commit/3a6ef14e78fa2e75b837bba6524751d65038f416)) +* **amazonq:** emit failed status for amazonq_invokeLLM ([#2071](https://github.com/aws/language-servers/issues/2071)) ([ee52a41](https://github.com/aws/language-servers/commit/ee52a41bc869b275fff708d7955b59f43b93bbd4)) +* **amazonq:** fix fallout of [#2051](https://github.com/aws/language-servers/issues/2051) ([#2057](https://github.com/aws/language-servers/issues/2057)) ([565066b](https://github.com/aws/language-servers/commit/565066bb61adda60333c9646db958d4208bcc8af)) +* **amazonq:** leverage lcs to find the chars added and removed ([#2092](https://github.com/aws/language-servers/issues/2092)) ([40379a8](https://github.com/aws/language-servers/commit/40379a887f8d42cc184239ca3175b4e673cc5286)) +* **amazonq:** skips continuous monitoring when WCS sees workspace as idle ([#2066](https://github.com/aws/language-servers/issues/2066)) ([9cb959d](https://github.com/aws/language-servers/commit/9cb959d4cc450d0907f8bf5265ba01d2aa68bcd0)) +* creating a new sesion for Edits trigger with next token ([#2094](https://github.com/aws/language-servers/issues/2094)) ([1da8730](https://github.com/aws/language-servers/commit/1da8730b6ed6ad53b6561368bf722e56d59596a4)) +* remove edit cache logic ([#2079](https://github.com/aws/language-servers/issues/2079)) ([9bc5b9c](https://github.com/aws/language-servers/commit/9bc5b9c1d77e5fee6f518f7f5016d3a0043a5a77)) +* sessionManager misused because there are 2 types of manager now ([#2090](https://github.com/aws/language-servers/issues/2090)) ([8db059a](https://github.com/aws/language-servers/commit/8db059ab83d94fd7c3ba3eb265044add31c80aea)) +* update client name to support Sagemaker AI origin for agentic chat ([#2093](https://github.com/aws/language-servers/issues/2093)) ([a746fe8](https://github.com/aws/language-servers/commit/a746fe845d5e09563b475f01ce44059dca9fd10f)) + +## [0.0.72](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.71...lsp-codewhisperer/v0.0.72) (2025-08-06) + + +### Features + +* add support for SMUS Q CodeEditor client to send MD IDE origin ([#2032](https://github.com/aws/language-servers/issues/2032)) ([a8725b4](https://github.com/aws/language-servers/commit/a8725b4b7dcb7718864620721aa3633151e8877b)) +* **amazonq:** enable sonnet 4 for fra region ([#2069](https://github.com/aws/language-servers/issues/2069)) ([3a4b8df](https://github.com/aws/language-servers/commit/3a4b8df981b2c3b0532360a11472169fffec7924)) + + +### Bug Fixes + +* **amazonq:** add distinctive identifier for cloud trail ([#2059](https://github.com/aws/language-servers/issues/2059)) ([18bbc2c](https://github.com/aws/language-servers/commit/18bbc2c54f5cc72e2624020fc17214c448926b0e)) +* **amazonq:** fix to add disable/enable feature back to mcp servers ([#2052](https://github.com/aws/language-servers/issues/2052)) ([c03e017](https://github.com/aws/language-servers/commit/c03e017b9ccbbbb9c80a3c3afd5da38a50bd6cff)) +* **amazonq:** make display findings tool run more often ([#2067](https://github.com/aws/language-servers/issues/2067)) ([479ccd0](https://github.com/aws/language-servers/commit/479ccd0a1b8b7e98684275c66274d284599c5933)) +* outdated history when trimming happens, add missing metric for compaction ([#2047](https://github.com/aws/language-servers/issues/2047)) ([8390f66](https://github.com/aws/language-servers/commit/8390f6686c804dfbeff91018635df21e9dd89236)) +* should keep reporting UTDE telemetry if there are still pending Edits suggestions ([#2051](https://github.com/aws/language-servers/issues/2051)) ([78c67b1](https://github.com/aws/language-servers/commit/78c67b1a29821f54006d160695e997870d17f3b5)) + ## [0.0.71](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.70...lsp-codewhisperer/v0.0.71) (2025-08-04) diff --git a/server/aws-lsp-codewhisperer/package.json b/server/aws-lsp-codewhisperer/package.json index 2c7366ff34..83ff1cdaee 100644 --- a/server/aws-lsp-codewhisperer/package.json +++ b/server/aws-lsp-codewhisperer/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-codewhisperer", - "version": "0.0.71", + "version": "0.0.75", "description": "CodeWhisperer Language Server", "main": "out/index.js", "repository": { @@ -36,8 +36,8 @@ "@aws-sdk/util-arn-parser": "^3.723.0", "@aws-sdk/util-retry": "^3.374.0", "@aws/chat-client-ui-types": "^0.1.56", - "@aws/language-server-runtimes": "^0.2.121", - "@aws/lsp-core": "^0.0.13", + "@aws/language-server-runtimes": "^0.2.127", + "@aws/lsp-core": "^0.0.14", "@modelcontextprotocol/sdk": "^1.15.0", "@smithy/node-http-handler": "^2.5.0", "adm-zip": "^0.5.10", diff --git a/server/aws-lsp-codewhisperer/src/client/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 2120382d25..7e0533b0f5 100644 --- a/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json +++ b/server/aws-lsp-codewhisperer/src/client/token/bearer-token-service.json @@ -516,6 +516,37 @@ ], "documentation": "

API to get code transformation status.

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

Get the requested CodeWhisperer profile.

" + }, "GetUsageLimits": { "name": "GetUsageLimits", "http": { @@ -3126,6 +3157,24 @@ } } }, + "GetProfileRequest": { + "type": "structure", + "required": ["profileArn"], + "members": { + "profileArn": { + "shape": "ProfileArn" + } + } + }, + "GetProfileResponse": { + "type": "structure", + "required": ["profile"], + "members": { + "profile": { + "shape": "ProfileInfo" + } + } + }, "GetTaskAssistCodeGenerationRequest": { "type": "structure", "required": ["conversationId", "codeGenerationId"], @@ -3573,6 +3622,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

" @@ -3787,6 +3840,15 @@ "type": "long", "box": true }, + "MCPConfiguration": { + "type": "structure", + "required": ["toggle"], + "members": { + "toggle": { + "shape": "OptInFeatureToggle" + } + } + }, "MemoryEntry": { "type": "structure", "required": ["id", "memoryEntryString", "metadata"], @@ -3897,6 +3959,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

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

You may close this tab.

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