diff --git a/package-lock.json b/package-lock.json index 2a0d079f189..e4f28c8510f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "vscode-nls-dev": "^4.0.4" }, "devDependencies": { - "@aws-toolkits/telemetry": "^1.0.312", + "@aws-toolkits/telemetry": "^1.0.313", "@playwright/browser-chromium": "^1.43.1", "@stylistic/eslint-plugin": "^2.11.0", "@types/he": "^1.2.3", @@ -33,7 +33,7 @@ "@vscode/test-electron": "^2.3.8", "@vscode/test-web": "^0.0.65", "@vscode/vsce": "^2.19.0", - "eslint": "^8.56.0", + "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-aws-toolkits": "file:plugins/eslint-plugin-aws-toolkits", "eslint-plugin-header": "^3.1.1", @@ -43,9 +43,10 @@ "husky": "^9.0.7", "prettier": "^3.3.3", "prettier-plugin-sh": "^0.14.0", - "pretty-quick": "^4.0.0", + "pretty-quick": "^4.1.1", "ts-node": "^10.9.1", "typescript": "^5.0.4", + "vscode-ripgrep": "^1.13.2", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.2", @@ -10806,9 +10807,9 @@ } }, "node_modules/@aws-toolkits/telemetry": { - "version": "1.0.312", - "resolved": "https://registry.npmjs.org/@aws-toolkits/telemetry/-/telemetry-1.0.312.tgz", - "integrity": "sha512-Ufr24XeVrkBrsyUZyGRXprclkGsF/5O16IXP0dW7LC2DMqFyMuvmcHhIkQDN9D8ydnsHdutj/ZxTyvpkHpXQJw==", + "version": "1.0.313", + "resolved": "https://registry.npmjs.org/@aws-toolkits/telemetry/-/telemetry-1.0.313.tgz", + "integrity": "sha512-GHlx2AoUfUuadXjZqMlQmpayVhnH1r6Ndbm7jOXvp80j3cwHqUdOgD/7gqK4mYKuxNBhEFC9yB5ieqmaH1lMuQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -11944,6 +11945,7 @@ "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.30.1.tgz", "integrity": "sha512-ZBtvmHYjlJXzIUCeDmNu1cFfJyO86S/+UCuM/LFbAV5mf4Qm1o8i0Gmpw/4ngKx3ZXdFGnVT1Iq2bCGSYhuoSw==", "hasInstallScript": true, + "license": "Apache License 2.0", "dependencies": { "escape-html": "^1.0.3", "highlight.js": "^11.11.0", @@ -12253,7 +12255,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.56.0", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true, "license": "MIT", "engines": { @@ -18194,15 +18198,18 @@ } }, "node_modules/eslint": { - "version": "8.56.0", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.56.0", - "@humanwhocodes/config-array": "^0.11.13", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -22505,7 +22512,9 @@ "license": "MIT" }, "node_modules/picocolors": { - "version": "1.0.1", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, "node_modules/picomatch": { @@ -22842,17 +22851,19 @@ } }, "node_modules/pretty-quick": { - "version": "4.0.0", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/pretty-quick/-/pretty-quick-4.1.1.tgz", + "integrity": "sha512-9Ud0l/CspNTmyIdYac9X7Inb3o8fuUsw+1zJFvCGn+at0t1UwUcUdo2RSZ41gcmfLv1fxgWQxWEfItR7CBwugg==", "dev": true, "license": "MIT", "dependencies": { - "execa": "^5.1.1", "find-up": "^5.0.0", - "ignore": "^5.3.0", + "ignore": "^7.0.3", "mri": "^1.2.0", - "picocolors": "^1.0.0", - "picomatch": "^3.0.1", - "tslib": "^2.6.2" + "picocolors": "^1.1.1", + "picomatch": "^4.0.2", + "tinyexec": "^0.3.2", + "tslib": "^2.8.1" }, "bin": { "pretty-quick": "lib/cli.mjs" @@ -22864,12 +22875,24 @@ "prettier": "^3.0.0" } }, + "node_modules/pretty-quick/node_modules/ignore": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz", + "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/pretty-quick/node_modules/picomatch": { - "version": "3.0.1", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -24091,6 +24114,13 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/shlex": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/shlex/-/shlex-2.1.2.tgz", + "integrity": "sha512-Nz6gtibMVgYeMEhUjp2KuwAgqaJA1K155dU/HuDaEJUGgnmYfVtVZah+uerVWdH8UGnyahhDCgABbYTbs254+w==", + "dev": true, + "license": "MIT" + }, "node_modules/side-channel": { "version": "1.0.6", "license": "MIT", @@ -24901,6 +24931,13 @@ "next-tick": "1" } }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.1", "dev": true, @@ -25047,7 +25084,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, "node_modules/tsscmp": { @@ -25609,6 +25648,43 @@ "node": ">=4.0.0" } }, + "node_modules/vscode-ripgrep": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/vscode-ripgrep/-/vscode-ripgrep-1.13.2.tgz", + "integrity": "sha512-RlK9U87EokgHfiOjDQ38ipQQX936gWOcWPQaJpYf+kAkz1PQ1pK2n7nhiscdOmLu6XGjTs7pWFJ/ckonpN7twQ==", + "deprecated": "This package has been renamed to @vscode/ripgrep, please update to the new name", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "https-proxy-agent": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/vscode-ripgrep/node_modules/agent-base": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-5.1.1.tgz", + "integrity": "sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/vscode-ripgrep/node_modules/https-proxy-agent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-4.0.0.tgz", + "integrity": "sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "5", + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/vscode-uri": { "version": "2.1.2", "license": "MIT" @@ -26841,6 +26917,7 @@ "readline-sync": "^1.4.9", "sass": "^1.49.8", "sass-loader": "^16.0.2", + "shlex": "^2.1.2", "sinon": "^14.0.0", "style-loader": "^3.3.1", "ts-node": "^10.9.1", @@ -30265,10 +30342,6 @@ "tree-kill": "cli.js" } }, - "src.gen/@amzn/amazon-q-developer-streaming-client/node_modules/tslib": { - "version": "2.8.1", - "license": "0BSD" - }, "src.gen/@amzn/amazon-q-developer-streaming-client/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -31832,10 +31905,6 @@ "tree-kill": "cli.js" } }, - "src.gen/@amzn/codewhisperer-streaming/node_modules/tslib": { - "version": "2.8.1", - "license": "0BSD" - }, "src.gen/@amzn/codewhisperer-streaming/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/package.json b/package.json index 637fc21649a..a746e55b01a 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "skippedTestReport": "ts-node ./scripts/skippedTestReport.ts ./packages/amazonq/test/e2e/" }, "devDependencies": { - "@aws-toolkits/telemetry": "^1.0.312", + "@aws-toolkits/telemetry": "^1.0.313", "@playwright/browser-chromium": "^1.43.1", "@stylistic/eslint-plugin": "^2.11.0", "@types/he": "^1.2.3", @@ -54,7 +54,7 @@ "@vscode/test-electron": "^2.3.8", "@vscode/test-web": "^0.0.65", "@vscode/vsce": "^2.19.0", - "eslint": "^8.56.0", + "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-aws-toolkits": "file:plugins/eslint-plugin-aws-toolkits", "eslint-plugin-header": "^3.1.1", @@ -64,9 +64,10 @@ "husky": "^9.0.7", "prettier": "^3.3.3", "prettier-plugin-sh": "^0.14.0", - "pretty-quick": "^4.0.0", + "pretty-quick": "^4.1.1", "ts-node": "^10.9.1", "typescript": "^5.0.4", + "vscode-ripgrep": "^1.13.2", "webpack": "^5.95.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.2", diff --git a/packages/amazonq/scripts/build/copyFiles.ts b/packages/amazonq/scripts/build/copyFiles.ts index 45b1d263f0b..aefd5c5f403 100644 --- a/packages/amazonq/scripts/build/copyFiles.ts +++ b/packages/amazonq/scripts/build/copyFiles.ts @@ -60,6 +60,11 @@ const tasks: CopyTask[] = [ target: path.join('../../node_modules', 'web-tree-sitter', 'tree-sitter.wasm'), destination: path.join('src', 'tree-sitter.wasm'), }, + // ripgrep binary + { + target: path.join('../../node_modules', 'vscode-ripgrep', 'bin'), + destination: 'bin/', + }, ] function copy(task: CopyTask): void { diff --git a/packages/amazonq/src/inlineChat/provider/inlineChatProvider.ts b/packages/amazonq/src/inlineChat/provider/inlineChatProvider.ts index e6534d65532..15314f94655 100644 --- a/packages/amazonq/src/inlineChat/provider/inlineChatProvider.ts +++ b/packages/amazonq/src/inlineChat/provider/inlineChatProvider.ts @@ -25,6 +25,7 @@ import { codeWhispererClient } from 'aws-core-vscode/codewhisperer' import type { InlineChatEvent } from 'aws-core-vscode/codewhisperer' import { InlineTask } from '../controller/inlineTask' import { extractAuthFollowUp } from 'aws-core-vscode/amazonq' +import { defaultContextLengths } from 'aws-core-vscode/codewhispererChat' export class InlineChatProvider { private readonly editorContextExtractor: EditorContextExtractor @@ -72,24 +73,7 @@ export class InlineChatProvider { additionalContents: [], documentReferences: [], useRelevantDocuments: false, - contextLengths: { - additionalContextLengths: { - fileContextLength: 0, - promptContextLength: 0, - ruleContextLength: 0, - }, - truncatedAdditionalContextLengths: { - fileContextLength: 0, - promptContextLength: 0, - ruleContextLength: 0, - }, - workspaceContextLength: 0, - truncatedWorkspaceContextLength: 0, - userInputContextLength: 0, - truncatedUserInputContextLength: 0, - focusFileContextLength: 0, - truncatedFocusFileContextLength: 0, - }, + contextLengths: defaultContextLengths, }, triggerID ) diff --git a/packages/core/package.json b/packages/core/package.json index fedea219112..e1b5646b6dc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -494,7 +494,8 @@ "umd-compat-loader": "^2.1.2", "vue-loader": "^17.2.2", "vue-style-loader": "^4.1.3", - "webfont": "^11.2.26" + "webfont": "^11.2.26", + "shlex": "^2.1.2" }, "dependencies": { "@amzn/amazon-q-developer-streaming-client": "file:../../src.gen/@amzn/amazon-q-developer-streaming-client", diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index 9c6ebe66067..a17953ea252 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -455,6 +455,11 @@ "AWS.amazonq.doc.pillText.makeChanges": "Make changes", "AWS.amazonq.inline.invokeChat": "Inline chat", "AWS.amazonq.opensettings:": "Open settings", + "AWS.amazonq.executeBash.run": "Run", + "AWS.amazonq.executeBash.reject": "Reject", + "AWS.amazonq.chat.directive.pairProgrammingModeOn": "You are using **pair programming mode**: Q can now list files, preview code diffs and allow you to run shell commands.", + "AWS.amazonq.chat.directive.pairProgrammingModeOff": "You turned off **pair programming mode**. Q will not include code diffs or run commands in the chat.", + "AWS.amazonq.chat.directive.runCommandToProceed": "Run the command to proceed.", "AWS.toolkit.lambda.walkthrough.quickpickTitle": "Application Builder Walkthrough", "AWS.toolkit.lambda.walkthrough.title": "Get started building your application", "AWS.toolkit.lambda.walkthrough.description": "Your quick guide to build an application visually, iterate locally, and deploy to the cloud!", diff --git a/packages/core/resources/css/amazonq-webview.css b/packages/core/resources/css/amazonq-webview.css index 5a3268be669..1117755cb6e 100644 --- a/packages/core/resources/css/amazonq-webview.css +++ b/packages/core/resources/css/amazonq-webview.css @@ -7,6 +7,104 @@ html { overflow: hidden; margin: 0; padding: 0; + + --mynah-font-family: var(--vscode-font-family), system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', + 'Amazon Ember', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + font-size: var(--vscode-font-size, 12px); + font-family: var(--mynah-font-family, 'system-ui'); + --mynah-max-width: 2560px; + --mynah-sizing-base: 0.2rem; + --mynah-sizing-half: calc(var(--mynah-sizing-base) / 2); + --mynah-sizing-1: var(--mynah-sizing-base); + --mynah-sizing-2: calc(var(--mynah-sizing-base) * 2); + --mynah-sizing-3: calc(var(--mynah-sizing-base) * 3); + --mynah-sizing-4: calc(var(--mynah-sizing-base) * 4); + --mynah-sizing-5: calc(var(--mynah-sizing-base) * 5); + --mynah-sizing-6: calc(var(--mynah-sizing-base) * 6); + --mynah-sizing-7: calc(var(--mynah-sizing-base) * 7); + --mynah-sizing-8: calc(var(--mynah-sizing-base) * 8); + --mynah-sizing-9: calc(var(--mynah-sizing-base) * 9); + --mynah-sizing-10: calc(var(--mynah-sizing-base) * 10); + --mynah-sizing-11: calc(var(--mynah-sizing-base) * 11); + --mynah-sizing-12: calc(var(--mynah-sizing-base) * 12); + --mynah-sizing-13: calc(var(--mynah-sizing-base) * 13); + --mynah-sizing-14: calc(var(--mynah-sizing-base) * 14); + --mynah-sizing-15: calc(var(--mynah-sizing-base) * 15); + --mynah-sizing-16: calc(var(--mynah-sizing-base) * 16); + --mynah-sizing-17: calc(var(--mynah-sizing-base) * 17); + --mynah-sizing-18: calc(var(--mynah-sizing-base) * 18); + --mynah-chat-wrapper-spacing: var(--mynah-sizing-2); + --mynah-button-border-width: 1px; + --mynah-border-width: 1px; + + --mynah-color-text-default: var(--vscode-foreground); + --mynah-color-text-strong: var(--vscode-input-foreground); + --mynah-color-text-weak: var(--vscode-disabledForeground); + --mynah-color-text-link: var(--vscode-textLink-foreground); + --mynah-color-text-input: var(--vscode-input-foreground); + + --mynah-color-bg: var(--vscode-sideBar-background); + --mynah-color-tab-active: var(--vscode-tab-activeBackground, var(--vscode-editor-background, var(--mynah-card-bg))); + --mynah-color-light: rgba(0, 0, 0, 0.05); + + --mynah-color-border-default: var(--vscode-panel-border, var(--vscode-tab-border, rgba(0, 0, 0, 0.1))); + + --mynah-color-highlight: rgba(255, 179, 0, 0.25); + --mynah-color-highlight-text: #886411; + + --mynah-color-toggle: var(--vscode-sideBar-background); + --mynah-color-toggle-reverse: rgba(0, 0, 0, 0.5); + + --mynah-color-syntax-bg: var(--vscode-terminal-dropBackground); + --mynah-color-syntax-variable: var(--vscode-debugTokenExpression-name); + --mynah-color-syntax-function: var(--vscode-gitDecoration-modifiedResourceForeground); + --mynah-color-syntax-operator: var(--vscode-debugTokenExpression-name); + --mynah-color-syntax-attr-value: var(--vscode-debugIcon-stepBackForeground); + --mynah-color-syntax-attr: var(--vscode-debugTokenExpression-string); + --mynah-color-syntax-property: var(--vscode-terminal-ansiCyan); + --mynah-color-syntax-comment: var(--vscode-debugConsole-sourceForeground); + --mynah-color-syntax-code: var(--vscode-terminal-ansiBlue); + --mynah-syntax-code-font-family: var(--vscode-editor-font-family); + --mynah-syntax-code-font-size: var(--vscode-editor-font-size, var(--mynah-font-size-medium)); + --mynah-syntax-code-block-max-height: calc(25 * var(--mynah-syntax-code-line-height)); + + --mynah-color-status-info: var(--vscode-editorInfo-foreground); + --mynah-color-status-success: var(--vscode-terminal-ansiGreen); + --mynah-color-status-warning: var(--vscode-editorWarning-foreground); + --mynah-color-status-error: var(--vscode-editorError-foreground); + + --mynah-color-button: var(--vscode-button-background); + --mynah-color-button-reverse: var(--vscode-button-foreground); + + --mynah-color-alternate: var(--vscode-button-secondaryBackground); + --mynah-color-alternate-reverse: var(--vscode-button-secondaryForeground); + + --mynah-card-bg: var(--vscode-editor-background); + + --mynah-shadow-button: none; + --mynah-shadow-card: none; + --mynah-shadow-overlay: 0 0px 15px -5px rgba(0, 0, 0, 0.4); + + --mynah-font-size-xxsmall: 0.75rem; + --mynah-font-size-xsmall: 0.85rem; + --mynah-font-size-small: 0.95rem; + --mynah-font-size-medium: 1rem; + --mynah-font-size-large: 1.125rem; + --mynah-line-height: 1.1rem; + --mynah-syntax-code-line-height: 1.1rem; + + --mynah-card-radius: var(--mynah-sizing-2); + --mynah-input-radius: var(--mynah-sizing-1); + --mynah-card-radius-corner: 0; + --mynah-button-radius: var(--mynah-sizing-1); + + --mynah-bottom-panel-transition: all 850ms cubic-bezier(0.25, 1, 0, 1); + --mynah-very-short-transition: all 600ms cubic-bezier(0.25, 1, 0, 1); + --mynah-very-long-transition: all 1650ms cubic-bezier(0.25, 1, 0, 1); + --mynah-short-transition: all 550ms cubic-bezier(0.85, 0.15, 0, 1); + --mynah-short-transition-rev: all 580ms cubic-bezier(0.35, 1, 0, 1); + --mynah-short-transition-rev-opacity: opacity 750ms cubic-bezier(0.35, 1, 0, 1); + --mynah-text-flow-transition: all 800ms cubic-bezier(0.35, 1.2, 0, 1), transform 800ms cubic-bezier(0.2, 1.05, 0, 1); } body.vscode-dark, @@ -15,3 +113,18 @@ body.vscode-high-contrast:not(.vscode-high-contrast-light) { --mynah-color-highlight: rgba(0, 137, 255, 0.2); --mynah-color-highlight-text: rgba(0, 137, 255, 1); } + +body .mynah-card-body h1 { + --mynah-line-height: 1.5rem; + font-size: 1.25em; +} + +body .mynah-card-body h2, +body .mynah-card-body h3, +body .mynah-card-body h4 { + font-size: 1em; +} + +div.mynah-card.padding-large { + padding: var(--mynah-sizing-4) var(--mynah-sizing-3); +} diff --git a/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts b/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts index 10d0aba9f26..265f6b3aaf2 100644 --- a/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/baseConnector.ts @@ -226,6 +226,15 @@ export abstract class BaseConnector { }) } + onPromptInputOptionChange = (tabId: string, optionsValues: Record): void => { + this.sendMessageToExtension({ + command: 'prompt-input-option-change', + optionsValues, + tabType: this.getTabType(), + tabID: tabId, + }) + } + requestGenerativeAIAnswer = (tabID: string, messageId: string, payload: ChatPayload): Promise => { /** * When a user presses "enter" send an event that indicates diff --git a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts index 324616a445a..174dfa1a9a1 100644 --- a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts @@ -3,7 +3,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatItemButton, ChatItemFormItem, ChatItemType, MynahUIDataModel, QuickActionCommand } from '@aws/mynah-ui' +import { + ChatItem, + ChatItemButton, + ChatItemFormItem, + ChatItemType, + MynahIconsType, + MynahUIDataModel, + QuickActionCommand, +} from '@aws/mynah-ui' import { TabType } from '../storages/tabsStorage' import { CWCChatItem } from '../connector' import { BaseConnector, BaseConnectorProps } from './baseConnector' @@ -18,12 +26,24 @@ export interface ConnectorProps extends BaseConnectorProps { title?: string, description?: string ) => void + onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void + onAsyncEventProgress: ( + tabID: string, + inProgress: boolean, + message: string, + messageId: string | undefined, + enableStopAction: boolean, + isPromptInputDisabled: boolean + ) => void } export class Connector extends BaseConnector { private readonly onCWCContextCommandMessage private readonly onContextCommandDataReceived private readonly onShowCustomForm + private readonly onChatAnswerUpdated + private readonly onAsyncEventProgress + private chatItems: Map> = new Map() // tabId -> messageId -> ChatItem override getTabType(): TabType { return 'cwc' @@ -34,6 +54,8 @@ export class Connector extends BaseConnector { this.onCWCContextCommandMessage = props.onCWCContextCommandMessage this.onContextCommandDataReceived = props.onContextCommandDataReceived this.onShowCustomForm = props.onShowCustomForm + this.onChatAnswerUpdated = props.onChatAnswerUpdated + this.onAsyncEventProgress = props.onAsyncEventProgress } onSourceLinkClick = (tabID: string, messageId: string, link: string): void => { @@ -91,16 +113,19 @@ export class Connector extends BaseConnector { messageId: messageData.messageID ?? messageData.triggerID, body: messageData.message, followUp: followUps, - canBeVoted: true, + canBeVoted: messageData.canBeVoted ?? false, codeReference: messageData.codeReference, userIntent: messageData.userIntent, codeBlockLanguage: messageData.codeBlockLanguage, contextList: messageData.contextList, - } - - // If it is not there we will not set it - if (messageData.messageType === 'answer-part' || messageData.messageType === 'answer') { - answer.canBeVoted = true + title: messageData.title, + buttons: messageData.buttons ?? undefined, + fileList: messageData.fileList ?? undefined, + header: messageData.header ?? undefined, + padding: messageData.padding ?? undefined, + fullWidth: messageData.fullWidth ?? undefined, + codeBlockActions: messageData.codeBlockActions ?? undefined, + rootFolderTitle: messageData.rootFolderTitle ?? undefined, } if (messageData.relatedSuggestions !== undefined) { @@ -109,6 +134,10 @@ export class Connector extends BaseConnector { content: messageData.relatedSuggestions, } } + + if (answer.messageId) { + this.storeChatItem(messageData.tabID, answer.messageId, answer) + } this.onChatAnswerReceived(messageData.tabID, answer, messageData) // Exit the function if we received an answer from AI @@ -137,6 +166,12 @@ export class Connector extends BaseConnector { options: messageData.followUps, } : undefined, + buttons: messageData.buttons ?? undefined, + canBeVoted: messageData.canBeVoted ?? false, + header: messageData.header ?? undefined, + padding: messageData.padding ?? undefined, + fullWidth: messageData.fullWidth ?? undefined, + codeBlockActions: messageData.codeBlockActions ?? undefined, } this.onChatAnswerReceived(messageData.tabID, answer, messageData) @@ -144,6 +179,47 @@ export class Connector extends BaseConnector { } } + private processToolMessage = async (messageData: any): Promise => { + if (this.onChatAnswerUpdated === undefined) { + return + } + const answer: CWCChatItem = { + type: messageData.messageType, + messageId: messageData.messageID ?? messageData.triggerID, + body: messageData.message, + followUp: messageData.followUps, + canBeVoted: messageData.canBeVoted ?? false, + codeReference: messageData.codeReference, + userIntent: messageData.contextList, + codeBlockLanguage: messageData.codeBlockLanguage, + contextList: messageData.contextList, + title: messageData.title, + buttons: messageData.buttons, + fileList: messageData.fileList, + header: messageData.header ?? undefined, + padding: messageData.padding ?? undefined, + fullWidth: messageData.fullWidth ?? undefined, + codeBlockActions: messageData.codeBlockActions ?? undefined, + rootFolderTitle: messageData.rootFolderTitle, + } + this.onChatAnswerUpdated(messageData.tabID, answer) + return + } + + private storeChatItem(tabId: string, messageId: string, item: ChatItem): void { + if (!this.chatItems.has(tabId)) { + this.chatItems.set(tabId, new Map()) + } + this.chatItems.get(tabId)?.set(messageId, { ...item }) + } + + private getCurrentChatItem(tabId: string, messageId: string | undefined): ChatItem | undefined { + if (!messageId) { + return + } + return this.chatItems.get(tabId)?.get(messageId) + } + processContextCommandData(messageData: any) { if (messageData.data) { this.onContextCommandDataReceived(messageData.data) @@ -189,6 +265,11 @@ export class Connector extends BaseConnector { return } + if (messageData.type === 'toolMessage') { + await this.processToolMessage(messageData) + return + } + if (messageData.type === 'editorContextCommandMessage') { await this.processEditorContextCommandMessage(messageData) return @@ -204,7 +285,21 @@ export class Connector extends BaseConnector { } if (messageData.type === 'customFormActionMessage') { - this.onCustomFormAction(messageData.tabID, messageData.action) + this.onCustomFormAction(messageData.tabID, messageData.messageId, messageData.action) + return + } + + if (messageData.type === 'asyncEventProgressMessage') { + const enableStopAction = false + const isPromptInputDisabled = true + this.onAsyncEventProgress( + messageData.tabID, + messageData.inProgress, + messageData.message ?? undefined, + messageData.messageId ?? undefined, + enableStopAction, + isPromptInputDisabled + ) return } // For other message types, call the base class handleMessageReceive @@ -235,6 +330,7 @@ export class Connector extends BaseConnector { onCustomFormAction( tabId: string, + messageId: string | undefined, action: { id: string text?: string | undefined @@ -245,12 +341,115 @@ export class Connector extends BaseConnector { return } + if (messageId?.startsWith('tooluse_')) { + action.formItemValues = { ...action.formItemValues, toolUseId: messageId } + } + this.sendMessageToExtension({ command: 'form-action-click', action: action, + formSelectedValues: action.formItemValues, tabType: this.getTabType(), tabID: tabId, }) + + if ( + !this.onChatAnswerUpdated || + ![ + 'accept-code-diff', + 'reject-code-diff', + 'run-shell-command', + 'reject-shell-command', + 'confirm-tool-use', + 'reject-tool-use', + ].includes(action.id) + ) { + return + } + + // Can not assign body as "undefined" or "null" because both of these values will be overriden at main.ts in onChatAnswerUpdated + // TODO: Refactor in next PR if necessary. + const currentChatItem = this.getCurrentChatItem(tabId, messageId) + const answer: ChatItem = { + type: ChatItemType.ANSWER, + messageId: messageId, + buttons: [], + body: undefined, + header: currentChatItem?.header ? { ...currentChatItem.header } : {}, + } + switch (action.id) { + case 'accept-code-diff': + if (answer.header) { + answer.header.status = { + icon: 'ok' as MynahIconsType, + text: 'Accepted', + status: 'success', + } + answer.header.buttons = [] + answer.body = ' ' + } + break + case 'reject-code-diff': + if (answer.header) { + answer.header.status = { + icon: 'cancel' as MynahIconsType, + text: 'Rejected', + status: 'error', + } + answer.header.buttons = [] + answer.body = ' ' + } + break + case 'run-shell-command': + if (answer.header) { + answer.header.status = { + icon: 'ok' as MynahIconsType, + text: 'Accepted', + status: 'success', + } + answer.header.buttons = [] + } + break + case 'reject-shell-command': + if (answer.header) { + answer.header.status = { + icon: 'cancel' as MynahIconsType, + text: 'Rejected', + status: 'error', + } + answer.header.buttons = [] + } + break + case 'confirm-tool-use': + if (answer.header) { + answer.header.status = { + icon: 'ok' as MynahIconsType, + text: 'Accepted', + status: 'success', + } + answer.header.buttons = [] + } + break + case 'reject-tool-use': + if (answer.header) { + answer.header.status = { + icon: 'cancel' as MynahIconsType, + text: 'Rejected', + status: 'error', + } + answer.header.buttons = [] + } + break + default: + break + } + + if (currentChatItem && answer.messageId) { + const updatedItem = { ...currentChatItem, ...answer } + this.storeChatItem(tabId, answer.messageId, updatedItem) + } + + this.onChatAnswerUpdated(tabId, answer) } onFileClick = (tabID: string, filePath: string, messageId?: string) => { diff --git a/packages/core/src/amazonq/webview/ui/commands.ts b/packages/core/src/amazonq/webview/ui/commands.ts index 5343c84b7f5..4c8a3463407 100644 --- a/packages/core/src/amazonq/webview/ui/commands.ts +++ b/packages/core/src/amazonq/webview/ui/commands.ts @@ -25,6 +25,7 @@ type MessageCommand = | 'help' | 'chat-item-voted' | 'chat-item-feedback' + | 'prompt-input-option-change' | 'link-was-clicked' | 'onboarding-page-interaction' | 'source-link-click' diff --git a/packages/core/src/amazonq/webview/ui/connector.ts b/packages/core/src/amazonq/webview/ui/connector.ts index 1c31f6cc842..04a740624f1 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -65,6 +65,8 @@ export interface CWCChatItem extends ChatItem { userIntent?: UserIntent codeBlockLanguage?: string contextList?: Context[] + title?: string + rootFolderTitle?: string } export interface Context { @@ -76,7 +78,7 @@ export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void onRunTestMessageReceived?: (tabID: string, showRunTestMessage: boolean) => void - onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void + onChatAnswerUpdated?: (tabID: string, message: CWCChatItem) => void onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void onWelcomeFollowUpClicked: (tabID: string, welcomeFollowUpType: WelcomeFollowupType) => void onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string | undefined) => void @@ -98,6 +100,7 @@ export interface ConnectorProps { onUpdateAuthentication: (featureDevEnabled: boolean, authenticatingTabIDs: string[]) => void onNewTab: (tabType: TabType, chats?: ChatItem[]) => string | undefined onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string) => void + onPromptInputOptionChange: (tabId: string, optionsValues: Record, eventId?: string) => void handleCommand: (chatPrompt: ChatPrompt, tabId: string) => void sendStaticMessages: (tabID: string, messages: ChatItem[]) => void onContextCommandDataReceived: (message: MynahUIDataModel['contextCommands']) => void @@ -627,6 +630,15 @@ export class Connector { } } + onPromptInputOptionChange = (tabId: string, optionsValues: Record): void => { + switch (this.tabsStorage.getTab(tabId)?.type) { + case 'unknown': + case 'cwc': + this.cwChatConnector.onPromptInputOptionChange(tabId, optionsValues) + break + } + } + sendFeedback = (tabId: string, feedbackPayload: FeedbackPayload): void | undefined => { switch (this.tabsStorage.getTab(tabId)?.type) { case 'featuredev': @@ -732,7 +744,7 @@ export class Connector { tabType: 'cwc', }) } else { - this.cwChatConnector.onCustomFormAction(tabId, action) + this.cwChatConnector.onCustomFormAction(tabId, messageId ?? '', action) } break case 'agentWalkthrough': { diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index cfce87b5d4e..3204ebe65b7 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -100,6 +100,41 @@ export const createMynahUI = ( welcomeCount += 1 } + /** + * Creates a file list header from context list + * @param contextList List of file contexts + * @param rootFolderTitle Title for the root folder + * @returns Header object with file list + */ + const createFileListHeader = (contextList: any[], rootFolderTitle?: string) => { + return { + fileList: { + fileTreeTitle: '', + filePaths: contextList.map((file) => file.relativeFilePath), + rootFolderTitle: rootFolderTitle, + flatList: true, + collapsed: true, + hideFileCount: true, + details: Object.fromEntries( + contextList.map((file) => [ + file.relativeFilePath, + { + label: file.lineRanges + .map((range: { first: number; second: number }) => + range.first === -1 || range.second === -1 + ? '' + : `line ${range.first} - ${range.second}` + ) + .join(', '), + description: file.relativeFilePath, + clickable: true, + }, + ]) + ), + }, + } + } + // Adding the first tab as CWC tab tabsStorage.addTab({ id: 'tab-1', @@ -260,6 +295,7 @@ export const createMynahUI = ( return '' }, onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string): void => {}, + onPromptInputOptionChange: (tabId: string, optionsValues: Record, eventId?: string): void => {}, onQuickHandlerCommand: (tabID: string, command?: string, eventId?: string) => { tabsStorage.updateTabLastCommand(tabID, command) if (command === 'aws.awsq.transform') { @@ -307,12 +343,13 @@ export const createMynahUI = ( inProgress: boolean, message: string | undefined, messageId: string | undefined = undefined, - enableStopAction: boolean = false + enableStopAction: boolean = false, + isPromptInputDisabled: boolean = true ) => { if (inProgress) { mynahUI.updateStore(tabID, { loadingChat: true, - promptInputDisabledState: true, + promptInputDisabledState: isPromptInputDisabled, cancelButtonWhenLoading: enableStopAction, }) @@ -344,14 +381,23 @@ export const createMynahUI = ( sendMessageToExtension: (message) => { ideApi.postMessage(message) }, - onChatAnswerUpdated: (tabID: string, item: ChatItem) => { + onChatAnswerUpdated: (tabID: string, item: CWCChatItem) => { if (item.messageId !== undefined) { + if (item.contextList !== undefined && item.contextList.length > 0) { + item.header = createFileListHeader(item.contextList, item.rootFolderTitle) + } mynahUI.updateChatAnswerWithMessageId(tabID, item.messageId, { ...(item.body !== undefined ? { body: item.body } : {}), ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), ...(item.footer !== undefined ? { footer: item.footer } : {}), ...(item.canBeVoted !== undefined ? { canBeVoted: item.canBeVoted } : {}), + ...(item.fileList !== undefined ? { fileList: item.fileList } : {}), + ...(item.header !== undefined ? { header: item.header } : {}), + ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), + ...(item.fullWidth !== undefined ? { fullWidth: item.fullWidth } : {}), + ...(item.padding !== undefined ? { padding: item.padding } : {}), + ...(item.codeBlockActions !== undefined ? { codeBlockActions: item.codeBlockActions } : {}), }) } else { mynahUI.updateLastChatAnswer(tabID, { @@ -360,6 +406,11 @@ export const createMynahUI = ( ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), ...(item.footer !== undefined ? { footer: item.footer } : {}), ...(item.canBeVoted !== undefined ? { canBeVoted: item.canBeVoted } : {}), + ...(item.header !== undefined ? { header: item.header } : {}), + ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), + ...(item.fullWidth !== undefined ? { fullWidth: item.fullWidth } : {}), + ...(item.padding !== undefined ? { padding: item.padding } : {}), + ...(item.codeBlockActions !== undefined ? { codeBlockActions: item.codeBlockActions } : {}), }) } }, @@ -373,7 +424,11 @@ export const createMynahUI = ( ...(item.relatedContent !== undefined ? { relatedContent: item.relatedContent } : {}), ...(item.followUp !== undefined ? { followUp: item.followUp } : {}), ...(item.fileList !== undefined ? { fileList: item.fileList } : {}), - ...(item.header !== undefined ? { header: item.header } : { header: undefined }), + ...(item.header !== undefined ? { header: item.header } : {}), + ...(item.buttons !== undefined ? { buttons: item.buttons } : {}), + ...(item.fullWidth !== undefined ? { fullWidth: item.fullWidth } : {}), + ...(item.padding !== undefined ? { padding: item.padding } : {}), + ...(item.codeBlockActions !== undefined ? { codeBlockActions: item.codeBlockActions } : {}), }) if ( item.messageId !== undefined && @@ -392,32 +447,7 @@ export const createMynahUI = ( } if (item.contextList !== undefined && item.contextList.length > 0) { - item.header = { - fileList: { - fileTreeTitle: '', - filePaths: item.contextList.map((file) => file.relativeFilePath), - rootFolderTitle: 'Context', - flatList: true, - collapsed: true, - hideFileCount: true, - details: Object.fromEntries( - item.contextList.map((file) => [ - file.relativeFilePath, - { - label: file.lineRanges - .map((range) => - range.first === -1 || range.second === -1 - ? '' - : `line ${range.first} - ${range.second}` - ) - .join(', '), - description: file.relativeFilePath, - clickable: true, - }, - ]) - ), - }, - } + item.header = createFileListHeader(item.contextList, item.rootFolderTitle) } if ( @@ -950,6 +980,9 @@ export const createMynahUI = ( onFileActionClick: async (tabID: string, messageId: string, filePath: string, actionName: string) => { connector.onFileActionClick(tabID, messageId, filePath, actionName) }, + onPromptInputOptionChange: (tabId, optionsValues) => { + connector.onPromptInputOptionChange(tabId, optionsValues) + }, onFileClick: connector.onFileClick, tabs: { 'tab-1': { diff --git a/packages/core/src/amazonq/webview/ui/tabs/generator.ts b/packages/core/src/amazonq/webview/ui/tabs/generator.ts index 9698a9d8076..dfb98f23590 100644 --- a/packages/core/src/amazonq/webview/ui/tabs/generator.ts +++ b/packages/core/src/amazonq/webview/ui/tabs/generator.ts @@ -87,6 +87,27 @@ export class TabDataGenerator { }, ] : [...(regionProfileCard ? [regionProfileCard] : [])], + promptInputOptions: + tabType === 'cwc' + ? [ + { + type: 'toggle', + id: 'prompt-type', + value: 'pair-programming-on', + tooltip: 'Pair programmer on', + options: [ + { + value: 'pair-programming-on', + icon: 'code-block', // TODO: correct icons + }, + { + value: 'pair-programming-off', + icon: 'chat', // TODO: correct icons + }, + ], + }, + ] + : [], } return tabData } diff --git a/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts b/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts index 5541ef389c5..c5359a2bfb8 100644 --- a/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts +++ b/packages/core/src/amazonqTest/chat/controller/messenger/messenger.ts @@ -27,18 +27,16 @@ import { ChatItemType } from '../../../../amazonq/commons/model' import { ChatItemAction, ChatItemButton, ProgressField } from '@aws/mynah-ui' import * as CodeWhispererConstants from '../../../../codewhisperer/models/constants' import { TriggerPayload } from '../../../../codewhispererChat/controllers/chat/model' -import { - CodeWhispererStreamingServiceException, - GenerateAssistantResponseCommandOutput, -} from '@amzn/codewhisperer-streaming' +import { GenerateAssistantResponseCommandOutput } from '@amzn/codewhisperer-streaming' import { Session } from '../../session/session' import { CodeReference } from '../../../../amazonq/webview/ui/apps/amazonqCommonsConnector' -import { getHttpStatusCode, getRequestId, getTelemetryReasonDesc, ToolkitError } from '../../../../shared/errors' +import { getTelemetryReasonDesc, ToolkitError } from '../../../../shared/errors' import { sleep, waitUntil } from '../../../../shared/utilities/timeoutUtils' import { keys } from '../../../../shared/utilities/tsUtils' import { cancellingProgressField, testGenCompletedField } from '../../../models/constants' import { testGenState } from '../../../../codewhisperer/models/model' import { TelemetryHelper } from '../../../../codewhisperer/util/telemetryHelper' +import { extractErrorInfo } from '../../../../shared/utilities/messageUtil' export type UnrecoverableErrorType = 'no-project-found' | 'no-open-file-found' | 'invalid-file-type' @@ -249,26 +247,19 @@ export class Messenger { { timeout: 60000, truthy: true } ) .catch((error: any) => { - let errorMessage = 'Error reading chat stream.' - let statusCode = undefined - let requestID = undefined - if (error instanceof CodeWhispererStreamingServiceException) { - errorMessage = error.message - statusCode = getHttpStatusCode(error) ?? 0 - requestID = getRequestId(error) - } + const errorInfo = extractErrorInfo(error) let message = 'This error is reported to the team automatically. Please try sending your message again.' - if (errorMessage !== undefined) { - message += `\n\nDetails: ${errorMessage}` + if (errorInfo.errorMessage !== undefined) { + message += `\n\nDetails: ${errorInfo.errorMessage}` } - if (statusCode !== undefined) { - message += `\n\nStatus Code: ${statusCode}` + if (errorInfo.statusCode !== undefined) { + message += `\n\nStatus Code: ${errorInfo.statusCode}` } - if (requestID !== undefined) { - messageId = requestID - message += `\n\nRequest ID: ${requestID}` + if (errorInfo.requestId !== undefined) { + messageId = errorInfo.requestId + message += `\n\nRequest ID: ${errorInfo.requestId}` } this.sendMessage(message.trim(), tabID, 'answer') }) diff --git a/packages/core/src/codewhispererChat/app.ts b/packages/core/src/codewhispererChat/app.ts index 63147002339..a85dab67846 100644 --- a/packages/core/src/codewhispererChat/app.ts +++ b/packages/core/src/codewhispererChat/app.ts @@ -28,6 +28,7 @@ import { AcceptDiff, QuickCommandGroupActionClick, FileClick, + PromptInputOptionChange, TabBarButtonClick, SaveChatMessage, } from './controllers/chat/model' @@ -64,6 +65,7 @@ export function init(appContext: AmazonQAppInitContext) { processCustomFormAction: new EventEmitter(), processContextSelected: new EventEmitter(), processFileClick: new EventEmitter(), + processPromptInputOptionChange: new EventEmitter(), processTabBarButtonClick: new EventEmitter(), processSaveChat: new EventEmitter(), processDetailedListFilterChangeMessage: new EventEmitter(), @@ -130,6 +132,9 @@ export function init(appContext: AmazonQAppInitContext) { cwChatControllerEventEmitters.processContextSelected ), processFileClick: new MessageListener(cwChatControllerEventEmitters.processFileClick), + processPromptInputOptionChange: new MessageListener( + cwChatControllerEventEmitters.processPromptInputOptionChange + ), processTabBarButtonClick: new MessageListener( cwChatControllerEventEmitters.processTabBarButtonClick ), @@ -206,6 +211,9 @@ export function init(appContext: AmazonQAppInitContext) { cwChatControllerEventEmitters.processContextSelected ), processFileClick: new MessagePublisher(cwChatControllerEventEmitters.processFileClick), + processPromptInputOptionChange: new MessagePublisher( + cwChatControllerEventEmitters.processPromptInputOptionChange + ), processTabBarButtonClick: new MessagePublisher( cwChatControllerEventEmitters.processTabBarButtonClick ), diff --git a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts index c32f67cdac5..30c81673470 100644 --- a/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts +++ b/packages/core/src/codewhispererChat/clients/chat/v0/chat.ts @@ -4,41 +4,140 @@ */ import { SendMessageCommandOutput, SendMessageRequest } from '@amzn/amazon-q-developer-streaming-client' -import { GenerateAssistantResponseCommandOutput, GenerateAssistantResponseRequest } from '@amzn/codewhisperer-streaming' +import { + GenerateAssistantResponseCommandOutput, + GenerateAssistantResponseRequest, + ToolUse, +} from '@amzn/codewhisperer-streaming' import * as vscode from 'vscode' import { ToolkitError } from '../../../../shared/errors' import { createCodeWhispererChatStreamingClient } from '../../../../shared/clients/codewhispererChatClient' import { createQDeveloperStreamingClient } from '../../../../shared/clients/qDeveloperChatClient' import { UserWrittenCodeTracker } from '../../../../codewhisperer/tracker/userWrittenCodeTracker' +import { DocumentReference, PromptMessage } from '../../../controllers/chat/model' +import { FsWriteBackup } from '../../../../codewhispererChat/tools/fsWrite' +import { randomUUID } from '../../../../shared/crypto' + +export type ToolUseWithError = { + toolUse: ToolUse + error: Error | undefined +} export class ChatSession { - private sessionId?: string + private sessionId: string + /** + * _readFiles = list of files read from the project to gather context before generating response. + * _showDiffOnFileWrite = Controls whether to show diff view (true) or file context view (false) to the user + * _context = Additional context to be passed to the LLM for generating the response + * _messageIdToUpdate = messageId of a chat message to be updated, used for reducing consecutive tool messages + */ + private _readFiles: DocumentReference[] = [] + private _readFolders: DocumentReference[] = [] + private _toolUseWithError: ToolUseWithError | undefined + private _showDiffOnFileWrite: boolean = false + private _context: PromptMessage['context'] + private _pairProgrammingModeOn: boolean = true + private _fsWriteBackups: Map = new Map() /** * True if messages from local history have been sent to session. */ localHistoryHydrated: boolean = false + private _messageIdToUpdate: string | undefined + private _messageIdToUpdateListDirectory: string | undefined contexts: Map = new Map() // TODO: doesn't handle the edge case when two files share the same relativePath string but from different root // e.g. root_a/file1 vs root_b/file1 relativePathToWorkspaceRoot: Map = new Map() - public get sessionIdentifier(): string | undefined { + public get sessionIdentifier(): string { return this.sessionId } + public get messageIdToUpdate(): string | undefined { + return this._messageIdToUpdate + } + + public setMessageIdToUpdate(messageId: string | undefined) { + this._messageIdToUpdate = messageId + } + + public get messageIdToUpdateListDirectory(): string | undefined { + return this._messageIdToUpdateListDirectory + } + + public setMessageIdToUpdateListDirectory(messageId: string | undefined) { + this._messageIdToUpdateListDirectory = messageId + } + + public get pairProgrammingModeOn(): boolean { + return this._pairProgrammingModeOn + } + + public setPairProgrammingModeOn(pairProgrammingModeOn: boolean) { + this._pairProgrammingModeOn = pairProgrammingModeOn + } + + public get toolUseWithError(): ToolUseWithError | undefined { + return this._toolUseWithError + } + + public setToolUseWithError(toolUseWithError: ToolUseWithError | undefined) { + this._toolUseWithError = toolUseWithError + } + + public get context(): PromptMessage['context'] { + return this._context + } + + public setContext(context: PromptMessage['context']) { + this._context = context + } + + public get fsWriteBackups(): Map { + return this._fsWriteBackups + } + + public setFsWriteBackup(toolUseId: string, backup: FsWriteBackup) { + this._fsWriteBackups.set(toolUseId, backup) + } public tokenSource!: vscode.CancellationTokenSource constructor() { this.createNewTokenSource() + this.sessionId = randomUUID() } createNewTokenSource() { this.tokenSource = new vscode.CancellationTokenSource() } - public setSessionID(id?: string) { + public setSessionID(id: string) { this.sessionId = id } + public get readFiles(): DocumentReference[] { + return this._readFiles + } + public get readFolders(): DocumentReference[] { + return this._readFolders + } + public get showDiffOnFileWrite(): boolean { + return this._showDiffOnFileWrite + } + public setShowDiffOnFileWrite(value: boolean) { + this._showDiffOnFileWrite = value + } + public addToReadFiles(filePath: DocumentReference) { + this._readFiles.push(filePath) + } + public clearListOfReadFiles() { + this._readFiles = [] + } + public setReadFolders(folder: DocumentReference) { + this._readFolders.push(folder) + } + public clearListOfReadFolders() { + this._readFolders = [] + } async chatIam(chatRequest: SendMessageRequest): Promise { const client = await createQDeveloperStreamingClient() @@ -49,14 +148,6 @@ export class ChatSession { ) } - const responseStream = response.sendMessageResponse - for await (const event of responseStream) { - if ('messageMetadataEvent' in event) { - this.sessionId = event.messageMetadataEvent?.conversationId - break - } - } - UserWrittenCodeTracker.instance.onQFeatureInvoked() return response } @@ -64,10 +155,6 @@ export class ChatSession { async chatSso(chatRequest: GenerateAssistantResponseRequest): Promise { const client = await createCodeWhispererChatStreamingClient() - if (this.sessionId !== undefined && chatRequest.conversationState !== undefined) { - chatRequest.conversationState.conversationId = this.sessionId - } - const response = await client.generateAssistantResponse(chatRequest) if (!response.generateAssistantResponseResponse) { throw new ToolkitError( @@ -75,8 +162,6 @@ export class ChatSession { ) } - this.sessionId = response.conversationId - UserWrittenCodeTracker.instance.onQFeatureInvoked() return response diff --git a/packages/core/src/codewhispererChat/constants.ts b/packages/core/src/codewhispererChat/constants.ts index 84dd2dae292..7922c54f97c 100644 --- a/packages/core/src/codewhispererChat/constants.ts +++ b/packages/core/src/codewhispererChat/constants.ts @@ -4,6 +4,8 @@ */ import * as path from 'path' import fs from '../shared/fs/fs' +import { Tool } from '@amzn/codewhisperer-streaming' +import toolsJson from '../codewhispererChat/tools/tool_index.json' import { ContextLengths } from './controllers/chat/model' export const promptFileExtension = '.md' @@ -22,6 +24,17 @@ export const getUserPromptsDirectory = () => { export const createSavedPromptCommandId = 'create-saved-prompt' +export const tools: Tool[] = Object.entries(toolsJson).map(([, toolSpec]) => ({ + toolSpecification: { + ...toolSpec, + inputSchema: { json: toolSpec.inputSchema }, + }, +})) + +export const noWriteTools: Tool[] = tools.filter( + (tool) => !['fsWrite', 'executeBash'].includes(tool.toolSpecification?.name || '') +) + export const defaultContextLengths: ContextLengths = { additionalContextLengths: { fileContextLength: 0, @@ -40,3 +53,5 @@ export const defaultContextLengths: ContextLengths = { focusFileContextLength: 0, truncatedFocusFileContextLength: 0, } + +export const defaultStreamingResponseTimeoutInMs = 180_000 diff --git a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts index b4b17f35a64..83903286c3d 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/chatRequest/converter.ts @@ -7,6 +7,8 @@ import { ConversationState, CursorState, DocumentSymbol, SymbolType, TextDocumen import { AdditionalContentEntryAddition, ChatTriggerType, RelevantTextDocumentAddition, TriggerPayload } from '../model' import { undefinedIfEmpty } from '../../../../shared/utilities/textUtilities' import { getLogger } from '../../../../shared/logger/logger' +import vscode from 'vscode' +import { noWriteTools, tools } from '../../../constants' import { messageToChatMessage } from '../../../../shared/db/chatDb/util' const fqnNameSizeDownLimit = 1 @@ -146,6 +148,12 @@ export function triggerPayloadToChatRequest(triggerPayload: TriggerPayload): { }, } } + + if (document.relativeFilePath === 'amazonwebservices.amazon-q-vscode.Amazon Q Logs') { + getLogger().debug('Active file is Amazon Q Logs, filter it out in the chat request') + document = undefined + cursorState = undefined + } } // service will throw validation exception if string is empty @@ -167,10 +175,16 @@ export function triggerPayloadToChatRequest(triggerPayload: TriggerPayload): { cursorState, relevantDocuments: triggerPayload.relevantTextDocuments, useRelevantDocuments: triggerPayload.useRelevantDocuments, + workspaceFolders: vscode.workspace.workspaceFolders?.map(({ uri }) => uri.fsPath) ?? [], }, additionalContext: triggerPayload.additionalContents, + tools: triggerPayload.pairProgrammingModeOn ? tools : noWriteTools, + ...(triggerPayload.toolResults !== undefined && + triggerPayload.toolResults !== null && { toolResults: triggerPayload.toolResults }), }, userIntent: triggerPayload.userIntent, + ...(triggerPayload.origin !== undefined && + triggerPayload.origin !== null && { origin: triggerPayload.origin }), }, }, chatTriggerType, diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index ba2072eb6dc..03baaae6b87 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -32,8 +32,10 @@ import { DocumentReference, FileClick, RelevantTextDocumentAddition, + PromptInputOptionChange, TabBarButtonClick, SaveChatMessage, + AgenticChatInteractionType, } from './model' import { AppToWebViewMessageDispatcher, @@ -50,7 +52,12 @@ import { EditorContextCommand } from '../../commands/registerCommands' import { PromptsGenerator } from './prompts/promptsGenerator' import { TriggerEventsStorage } from '../../storages/triggerEvents' import { SendMessageRequest } from '@amzn/amazon-q-developer-streaming-client' -import { CodeWhispererStreamingServiceException } from '@amzn/codewhisperer-streaming' +import { + CodeWhispererStreamingServiceException, + Origin, + ToolResult, + ToolResultStatus, +} from '@amzn/codewhisperer-streaming' import { UserIntentRecognizer } from './userIntent/userIntentRecognizer' import { CWCTelemetryHelper, recordTelemetryChatRunCommand } from './telemetryHelper' import { CodeWhispererTracker } from '../../../codewhisperer/tracker/codewhispererTracker' @@ -86,8 +93,14 @@ import { defaultContextLengths, } from '../../constants' import { ChatSession } from '../../clients/chat/v0/chat' +import { amazonQTabSuffix } from '../../../shared/constants' +import { OutputKind } from '../../tools/toolShared' +import { ToolUtils, Tool, ToolType } from '../../tools/toolUtils' +import { ChatStream } from '../../tools/chatStream' +import { tempDirPath } from '../../../shared/filesystemUtilities' import { Database } from '../../../shared/db/chatDb/chatDb' import { TabBarController } from './tabBarController' +import { messageToChatMessage } from '../../../shared/db/chatDb/util' export interface ChatControllerMessagePublishers { readonly processPromptChatMessage: MessagePublisher @@ -112,6 +125,7 @@ export interface ChatControllerMessagePublishers { readonly processCustomFormAction: MessagePublisher readonly processContextSelected: MessagePublisher readonly processFileClick: MessagePublisher + readonly processPromptInputOptionChange: MessagePublisher readonly processTabBarButtonClick: MessagePublisher readonly processSaveChat: MessagePublisher readonly processDetailedListFilterChangeMessage: MessagePublisher @@ -142,6 +156,7 @@ export interface ChatControllerMessageListeners { readonly processCustomFormAction: MessageListener readonly processContextSelected: MessageListener readonly processFileClick: MessageListener + readonly processPromptInputOptionChange: MessageListener readonly processTabBarButtonClick: MessageListener readonly processSaveChat: MessageListener readonly processDetailedListFilterChangeMessage: MessageListener @@ -288,6 +303,9 @@ export class ChatController { this.chatControllerMessageListeners.processFileClick.onMessage((data) => { return this.processFileClickMessage(data) }) + this.chatControllerMessageListeners.processPromptInputOptionChange.onMessage((data) => { + return this.processPromptInputOptionChange(data) + }) this.chatControllerMessageListeners.processTabBarButtonClick.onMessage((data) => { return this.tabBarController.processTabBarButtonClick(data) }) @@ -335,7 +353,20 @@ export class ChatController { } private processResponseBodyLinkClick(click: ResponseBodyLinkClickMessage) { - this.openLinkInExternalBrowser(click) + const uri = vscode.Uri.parse(click.link) + if (uri.scheme === 'file') { + void this.openFile(uri.fsPath) + } else { + this.openLinkInExternalBrowser(click) + } + } + + private async openFile(absolutePath: string) { + const fileExists = await fs.existsFile(absolutePath) + if (fileExists) { + const document = await vscode.workspace.openTextDocument(absolutePath) + await vscode.window.showTextDocument(document) + } } private processSourceLinkClick(click: SourceLinkClickMessage) { @@ -383,6 +414,9 @@ export class ChatController { private async processStopResponseMessage(message: StopResponseMessage) { const session = this.sessionStorage.getSession(message.tabID) session.tokenSource.cancel() + this.messenger.sendEmptyMessage(message.tabID, '', undefined) + this.chatHistoryDb.clearRecentHistory(message.tabID) + this.telemetryHelper.recordInteractionWithAgenticChat(AgenticChatInteractionType.StopChat, message) } private async processTriggerTabIDReceived(message: TriggerTabIDReceived) { @@ -629,21 +663,238 @@ export class ChatController { this.handlePromptCreate(message.tabID) } } + private async handleCreatePrompt(message: CustomFormActionMessage) { + const userPromptsDirectory = getUserPromptsDirectory() - private async processCustomFormAction(message: CustomFormActionMessage) { - if (message.action.id === 'submit-create-prompt') { - const userPromptsDirectory = getUserPromptsDirectory() + const title = message.action.formItemValues?.['prompt-name'] + const newFilePath = path.join( + userPromptsDirectory, + title ? `${title}${promptFileExtension}` : `default${promptFileExtension}` + ) + const newFileContent = new Uint8Array(Buffer.from('')) + await fs.writeFile(newFilePath, newFileContent, { mode: 0o600 }) + const newFileDoc = await vscode.workspace.openTextDocument(newFilePath) + await vscode.window.showTextDocument(newFileDoc) + telemetry.ui_click.emit({ elementId: 'amazonq_createSavedPrompt' }) + } + + private async processToolUseMessage(message: CustomFormActionMessage) { + const tabID = message.tabID + if (!tabID) { + return + } + this.editorContextExtractor + .extractContextForTrigger('ChatMessage') + .then(async (context) => { + const triggerID = randomUUID() + this.triggerEventsStorage.addTriggerEvent({ + id: triggerID, + tabID: message.tabID, + message: undefined, + type: 'chat_message', + context, + }) + this.messenger.sendAsyncEventProgress(tabID, true, '') + const session = this.sessionStorage.getSession(tabID) + const toolUseWithError = session.toolUseWithError + getLogger().debug( + `processToolUseMessage: ${toolUseWithError?.toolUse.name}:${toolUseWithError?.toolUse.toolUseId} with error: ${toolUseWithError?.error}` + ) + if (!toolUseWithError || !toolUseWithError.toolUse) { + // Turn off AgentLoop flag if there's no tool use + this.sessionStorage.setAgentLoopInProgress(tabID, false) + return + } + session.setToolUseWithError(undefined) + + const toolUse = toolUseWithError.toolUse + const toolUseError = toolUseWithError.error + const toolResults: ToolResult[] = [] + + if (toolUseError) { + toolResults.push({ + content: [{ text: toolUseError.message }], + toolUseId: toolUse.toolUseId, + status: ToolResultStatus.ERROR, + }) + } else { + const result = ToolUtils.tryFromToolUse(toolUse) + if ('type' in result) { + const tool: Tool = result + + try { + await ToolUtils.validate(tool) + + const chatStream = new ChatStream( + this.messenger, + tabID, + triggerID, + toolUse, + session, + undefined, + false, + { + requiresAcceptance: false, + }, + false + ) + if (tool.type === ToolType.FsWrite && toolUse.toolUseId) { + const backup = await tool.tool.getBackup() + session.setFsWriteBackup(toolUse.toolUseId, backup) + } + const output = await ToolUtils.invoke(tool, chatStream) + ToolUtils.validateOutput(output) + + toolResults.push({ + content: [ + output.output.kind === OutputKind.Text + ? { text: output.output.content } + : { json: output.output.content }, + ], + toolUseId: toolUse.toolUseId, + status: ToolResultStatus.SUCCESS, + }) + } catch (e: any) { + toolResults.push({ + content: [{ text: e.message }], + toolUseId: toolUse.toolUseId, + status: ToolResultStatus.ERROR, + }) + } + } else { + const toolResult: ToolResult = result + toolResults.push(toolResult) + } + } + + await this.generateResponse( + { + message: '', + trigger: ChatTriggerType.ChatMessage, + query: undefined, + codeSelection: context?.focusAreaContext?.selectionInsideExtendedCodeBlock, + fileText: context?.focusAreaContext?.extendedCodeBlock ?? '', + fileLanguage: context?.activeFileContext?.fileLanguage, + filePath: context?.activeFileContext?.filePath, + matchPolicy: context?.activeFileContext?.matchPolicy, + codeQuery: context?.focusAreaContext?.names, + userIntent: undefined, + customization: getSelectedCustomization(), + toolResults: toolResults, + profile: AuthUtil.instance.regionProfileManager.activeRegionProfile, + origin: Origin.IDE, + context: session.context ?? [], + relevantTextDocuments: [], + additionalContents: [], + documentReferences: [], + useRelevantDocuments: false, + contextLengths: { + ...defaultContextLengths, + }, + }, + triggerID + ) + }) + .catch((e) => { + this.processException(e, tabID) + }) + } + + private async closeDiffView(message: CustomFormActionMessage) { + // Close the diff view if User rejected or accepted the generated code changes. + if (vscode.window.tabGroups.activeTabGroup.activeTab?.label.includes(amazonQTabSuffix)) { + await vscode.commands.executeCommand('workbench.action.closeActiveEditor') + } + // clean up temp file + const tabID = message.tabID + const toolUseId = message.action.formItemValues?.toolUseId + if (!tabID || !toolUseId) { + return + } + + const session = this.sessionStorage.getSession(tabID) + const { filePath } = session.fsWriteBackups.get(toolUseId) ?? {} + if (filePath) { + const tempFilePath = await this.getTempFilePath(filePath) + if (await fs.existsFile(tempFilePath)) { + await fs.delete(tempFilePath) + } + } + } + + private async rejectShellCommand(message: CustomFormActionMessage) { + const triggerId = randomUUID() + this.triggerEventsStorage.addTriggerEvent({ + id: triggerId, + tabID: message.tabID, + message: undefined, + type: 'chat_message', + context: undefined, + }) - const title = message.action.formItemValues?.['prompt-name'] - const newFilePath = path.join( - userPromptsDirectory, - title ? `${title}${promptFileExtension}` : `default${promptFileExtension}` + const session = this.sessionStorage.getSession(message.tabID!) + const currentToolUse = session.toolUseWithError?.toolUse + if ( + currentToolUse && + (currentToolUse.name === ToolType.ExecuteBash || + currentToolUse.name === ToolType.FsRead || + currentToolUse.name === ToolType.ListDirectory) + ) { + session.toolUseWithError.error = new Error('Tool use was rejected by the user.') + } else { + getLogger().error( + `toolUse name: ${currentToolUse!.name} of toolUseWithError in the stored session doesn't match when click shell command reject button.` ) - const newFileContent = new Uint8Array(Buffer.from('')) - await fs.writeFile(newFilePath, newFileContent, { mode: 0o600 }) - const newFileDoc = await vscode.workspace.openTextDocument(newFilePath) - await vscode.window.showTextDocument(newFileDoc) - telemetry.ui_click.emit({ elementId: 'amazonq_createSavedPrompt' }) + } + } + + private async processCustomFormAction(message: CustomFormActionMessage) { + switch (message.action.id) { + case 'submit-create-prompt': + await this.handleCreatePrompt(message) + break + case 'run-shell-command': + case 'generic-tool-execution': + case 'confirm-tool-use': + await this.processToolUseMessage(message) + if (message.action.id === 'run-shell-command' && message.action.text === 'Run') { + this.telemetryHelper.recordInteractionWithAgenticChat( + AgenticChatInteractionType.RunCommand, + message + ) + } + break + case 'accept-code-diff': + await this.closeDiffView(message) + break + case 'reject-code-diff': + await this.restoreBackup(message) + await this.closeDiffView(message) + this.telemetryHelper.recordInteractionWithAgenticChat(AgenticChatInteractionType.RejectDiff, message) + break + case 'reject-shell-command': + case 'reject-tool-use': + await this.rejectShellCommand(message) + await this.processToolUseMessage(message) + break + default: + getLogger().warn(`Unhandled action: ${message.action.id}`) + } + } + + private async restoreBackup(message: CustomFormActionMessage) { + const tabID = message.tabID + const toolUseId = message.action.formItemValues?.toolUseId + if (!tabID || !toolUseId) { + return + } + + const session = this.sessionStorage.getSession(tabID) + const { content, filePath, isNew } = session.fsWriteBackups.get(toolUseId) ?? {} + if (filePath && isNew) { + await fs.delete(filePath) + } else if (filePath && content !== undefined) { + await fs.writeFile(filePath, content) } } @@ -652,62 +903,126 @@ export class ChatController { this.handlePromptCreate(message.tabID) } } - private async processFileClickMessage(message: FileClick) { - const session = this.sessionStorage.getSession(message.tabID) - const lineRanges = session.contexts.get(message.filePath) - if (!lineRanges) { - return + private async processPromptInputOptionChange(message: PromptInputOptionChange) { + const session = this.sessionStorage.getSession(message.tabID) + const promptTypeValue = message.optionsValues['prompt-type'] + if (promptTypeValue === 'pair-programming-on') { + session.setPairProgrammingModeOn(true) + this.messenger.sendDirectiveMessage( + message.tabID, + promptTypeValue, + i18n('AWS.amazonq.chat.directive.pairProgrammingModeOn') + ) + } else { + this.messenger.sendDirectiveMessage( + message.tabID, + promptTypeValue, + i18n('AWS.amazonq.chat.directive.pairProgrammingModeOff') + ) + session.setPairProgrammingModeOn(false) } + } - // Check if clicked file is in a different workspace root - const projectRoot = - session.relativePathToWorkspaceRoot.get(message.filePath) || workspace.workspaceFolders?.[0]?.uri.fsPath - if (!projectRoot) { - return + private async getTempFilePath(filePath: string) { + // Create a temporary file path to show the diff view + const pathToArchiveDir = path.join(tempDirPath, 'q-chat') + const archivePathExists = await fs.existsDir(pathToArchiveDir) + if (!archivePathExists) { + await fs.mkdir(pathToArchiveDir) + } + const resultArtifactsDir = path.join(pathToArchiveDir, 'resultArtifacts') + const resultArtifactsDirExists = await fs.existsDir(resultArtifactsDir) + if (!resultArtifactsDirExists) { + await fs.mkdir(resultArtifactsDir) } - let absoluteFilePath = path.join(projectRoot, message.filePath) + return path.join(resultArtifactsDir, `temp-${path.basename(filePath)}`) + } + + private async processFileClickMessage(message: FileClick) { + const session = this.sessionStorage.getSession(message.tabID) + // Check if user clicked on filePath in the contextList or in the fileListTree and perform the functionality accordingly. + if (session.showDiffOnFileWrite) { + const toolUseId = message.messageId + const { filePath, content } = session.fsWriteBackups.get(toolUseId) ?? {} + if (!filePath || content === undefined) { + return + } - // Handle clicking on a user prompt outside the workspace - if (message.filePath.endsWith(promptFileExtension)) { try { - await vscode.workspace.fs.stat(vscode.Uri.file(absoluteFilePath)) - } catch { - absoluteFilePath = path.join(getUserPromptsDirectory(), message.filePath) + const tempFilePath = await this.getTempFilePath(filePath) + await fs.writeFile(tempFilePath, content) + + const leftUri = vscode.Uri.file(tempFilePath) + const rightUri = vscode.Uri.file(filePath) + const fileName = path.basename(filePath) + await vscode.commands.executeCommand( + 'vscode.diff', + leftUri, + rightUri, + `${fileName} ${amazonQTabSuffix}` + ) + } catch (error) { + getLogger().error(`Unexpected error in diff view generation: ${error}`) + void vscode.window.showErrorMessage(`Failed to open diff view.`) } - } + } else { + const lineRanges = session.contexts.get(message.filePath) - try { - // Open the file in VSCode - const document = await workspace.openTextDocument(absoluteFilePath) - const editor = await window.showTextDocument(document, ViewColumn.Active) - - // Create multiple selections based on line ranges - const selections: Selection[] = lineRanges - .filter(({ first, second }) => first !== -1 && second !== -1) - .map(({ first, second }) => { - const startPosition = new Position(first - 1, 0) // Convert 1-based to 0-based - const endPosition = new Position(second - 1, document.lineAt(second - 1).range.end.character) - return new Selection( - startPosition.line, - startPosition.character, - endPosition.line, - endPosition.character - ) - }) + if (!lineRanges) { + return + } - // Apply multiple selections to the editor - if (selections.length > 0) { - editor.selection = selections[0] // Set the first selection as active - editor.selections = selections // Apply multiple selections - editor.revealRange(selections[0], vscode.TextEditorRevealType.InCenter) + // Check if clicked file is in a different workspace root + const projectRoot = + session.relativePathToWorkspaceRoot.get(message.filePath) || workspace.workspaceFolders?.[0]?.uri.fsPath + if (!projectRoot) { + return + } + let absoluteFilePath = path.join(projectRoot, message.filePath) + + // Handle clicking on a user prompt outside the workspace + if (message.filePath.endsWith(promptFileExtension)) { + try { + await vscode.workspace.fs.stat(vscode.Uri.file(absoluteFilePath)) + } catch { + absoluteFilePath = path.join(getUserPromptsDirectory(), message.filePath) + } } - } catch (error) {} + + try { + // Open the file in VSCode + const document = await workspace.openTextDocument(absoluteFilePath) + const editor = await window.showTextDocument(document, ViewColumn.Active) + + // Create multiple selections based on line ranges + const selections: Selection[] = lineRanges + .filter(({ first, second }) => first !== -1 && second !== -1) + .map(({ first, second }) => { + const startPosition = new Position(first - 1, 0) // Convert 1-based to 0-based + const endPosition = new Position(second - 1, document.lineAt(second - 1).range.end.character) + return new Selection( + startPosition.line, + startPosition.character, + endPosition.line, + endPosition.character + ) + }) + + // Apply multiple selections to the editor + if (selections.length > 0) { + editor.selection = selections[0] // Set the first selection as active + editor.selections = selections // Apply multiple selections + editor.revealRange(selections[0], vscode.TextEditorRevealType.InCenter) + } + } catch (error) {} + } } private processException(e: any, tabID: string) { let errorMessage = '' let requestID = undefined + let statusCode = undefined const defaultMessage = 'Failed to get response' if (typeof e === 'string') { errorMessage = e.toUpperCase() @@ -717,14 +1032,21 @@ export class ChatController { } else if (e instanceof CodeWhispererStreamingServiceException) { errorMessage = e.message requestID = e.$metadata.requestId + statusCode = e.$metadata.httpStatusCode } else if (e instanceof Error) { errorMessage = e.message } - this.messenger.sendErrorMessage(errorMessage, tabID, requestID) + // Turn off AgentLoop flag in case of exception + if (tabID) { + this.sessionStorage.setAgentLoopInProgress(tabID, false) + } + + this.messenger.sendErrorMessage(errorMessage, tabID, requestID, statusCode) getLogger().error(`error: ${errorMessage} tabID: ${tabID} requestID: ${requestID}`) this.sessionStorage.deleteSession(tabID) + this.chatHistoryDb.clearTab(tabID) } private async processContextMenuCommand(command: EditorContextCommand) { @@ -903,9 +1225,13 @@ export class ChatController { } private async processPromptMessageAsNewThread(message: PromptMessage) { + const session = this.sessionStorage.getSession(message.tabID) + session.clearListOfReadFiles() + session.clearListOfReadFolders() + session.setShowDiffOnFileWrite(false) this.editorContextExtractor .extractContextForTrigger('ChatMessage') - .then((context) => { + .then(async (context) => { const triggerID = randomUUID() this.triggerEventsStorage.addTriggerEvent({ id: triggerID, @@ -914,7 +1240,10 @@ export class ChatController { type: 'chat_message', context, }) - return this.generateResponse( + + this.messenger.sendAsyncEventProgress(message.tabID, true, '') + + await this.generateResponse( { message: message.message ?? '', trigger: ChatTriggerType.ChatMessage, @@ -925,8 +1254,9 @@ export class ChatController { filePath: context?.activeFileContext?.filePath, matchPolicy: context?.activeFileContext?.matchPolicy, codeQuery: context?.focusAreaContext?.names, - userIntent: this.userIntentRecognizer.getFromPromptChatMessage(message), + userIntent: undefined, customization: getSelectedCustomization(), + origin: Origin.IDE, profile: AuthUtil.instance.regionProfileManager.activeRegionProfile, context: message.context ?? [], relevantTextDocuments: [], @@ -1108,6 +1438,16 @@ export class ChatController { } const tabID = triggerEvent.tabID + if (this.sessionStorage.isAgentLoopInProgress(tabID)) { + // If a response is already in progress, stop it first + const stopResponseMessage: StopResponseMessage = { + tabID: tabID, + } + await this.processStopResponseMessage(stopResponseMessage) + } + + // Ensure AgentLoop flag is set to true during response generation + this.sessionStorage.setAgentLoopInProgress(tabID, true) const credentialsState = await AuthUtil.instance.getChatAuthState() @@ -1119,10 +1459,6 @@ export class ChatController { } const session = this.sessionStorage.getSession(tabID) - if (!session.localHistoryHydrated) { - triggerPayload.history = this.chatHistoryDb.getMessages(triggerEvent.tabID, 10) - session.localHistoryHydrated = true - } await this.resolveContextCommandPayload(triggerPayload, session) triggerPayload.useRelevantDocuments = triggerPayload.context.some( (context) => typeof context !== 'string' && context.command === '@workspace' @@ -1157,7 +1493,19 @@ export class ChatController { triggerPayload.contextLengths.userInputContextLength = triggerPayload.message.length triggerPayload.contextLengths.focusFileContextLength = triggerPayload.fileText.length + triggerPayload.pairProgrammingModeOn = session.pairProgrammingModeOn + const request = triggerPayloadToChatRequest(triggerPayload) + + const currentMessage = request.conversationState.currentMessage + if (currentMessage) { + this.chatHistoryDb.fixHistory(tabID, currentMessage) + } + request.conversationState.history = this.chatHistoryDb + .getMessages(tabID) + .map((chat) => messageToChatMessage(chat)) + request.conversationState.conversationId = session.sessionIdentifier + triggerPayload.documentReferences = this.mergeRelevantTextDocuments(triggerPayload.relevantTextDocuments) // Update context transparency after it's truncated dynamically to show users only the context sent. @@ -1191,7 +1539,12 @@ export class ChatController { session.createNewTokenSource() // TODO: onProfileChanged, abort previous response? try { - this.messenger.sendInitalStream(tabID, triggerID, triggerPayload.documentReferences) + if (!session.context && triggerPayload.context.length) { + // Only show context for the first message in the loop + this.messenger.sendContextMessage(tabID, triggerID, triggerPayload.documentReferences) + session.setContext(triggerPayload.context) + } + this.messenger.sendInitalStream(tabID, triggerID) this.telemetryHelper.setConversationStreamStartTime(tabID) if (isSsoConnection(AuthUtil.instance.conn)) { const { $metadata, generateAssistantResponseResponse } = await session.chatSso(request) @@ -1208,10 +1561,14 @@ export class ChatController { } this.telemetryHelper.recordEnterFocusConversation(triggerEvent.tabID) this.telemetryHelper.recordStartConversation(triggerEvent, triggerPayload) - if (session.sessionIdentifier) { + + if (currentMessage && session.sessionIdentifier) { this.chatHistoryDb.addMessage(tabID, 'cwc', session.sessionIdentifier, { body: triggerPayload.message, type: 'prompt' as any, + userIntent: currentMessage.userInputMessage?.userIntent, + origin: currentMessage.userInputMessage?.origin, + userInputMessageContext: currentMessage.userInputMessage?.userInputMessageContext, }) } @@ -1229,8 +1586,13 @@ export class ChatController { triggerPayload, this.cancelTokenSource.token ) + + // Turn off AgentLoop flag after sending the AI response + this.sessionStorage.setAgentLoopInProgress(tabID, false) } catch (e: any) { this.telemetryHelper.recordMessageResponseError(triggerPayload, tabID, getHttpStatusCode(e) ?? 0) + // Turn off AgentLoop flag in case of exception + this.sessionStorage.setAgentLoopInProgress(tabID, false) // clears session, record telemetry before this call this.processException(e, tabID) } diff --git a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts index 8c914686ad4..29c644cc226 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/messenger/messenger.ts @@ -10,6 +10,7 @@ import { AuthNeededException, CodeReference, ContextCommandData, + CustomFormActionMessage, EditorContextCommandMessage, ExportChatMessage, OpenSettingsMessage, @@ -20,20 +21,22 @@ import { UpdateDetailedListMessage, CloseDetailedListMessage, SelectTabMessage, + ChatItemHeader, + ToolMessage, } from '../../../view/connector/connector' import { EditorContextCommandType } from '../../../commands/registerCommands' import { ChatResponseStream as qdevChatResponseStream } from '@amzn/amazon-q-developer-streaming-client' import { ChatResponseStream as cwChatResponseStream, - CodeWhispererStreamingServiceException, SupplementaryWebLink, + ToolUse, } from '@amzn/codewhisperer-streaming' import { ChatMessage, ErrorMessage, FollowUp, Suggestion } from '../../../view/connector/connector' import { ChatSession } from '../../../clients/chat/v0/chat' import { ChatException } from './model' import { CWCTelemetryHelper } from '../telemetryHelper' -import { ChatPromptCommandType, DocumentReference, TriggerPayload } from '../model' -import { getHttpStatusCode, getRequestId, ToolkitError } from '../../../../shared/errors' +import { AgenticChatInteractionType, ChatPromptCommandType, DocumentReference, TriggerPayload } from '../model' +import { ToolkitError } from '../../../../shared/errors' import { keys } from '../../../../shared/utilities/tsUtils' import { getLogger } from '../../../../shared/logger/logger' import { FeatureAuthState } from '../../../../codewhisperer/util/authUtil' @@ -44,9 +47,32 @@ import { LspController } from '../../../../amazonq/lsp/lspController' import { extractCodeBlockLanguage } from '../../../../shared/markdown' import { extractAuthFollowUp } from '../../../../amazonq/util/authUtils' import { helpMessage } from '../../../../amazonq/webview/ui/texts/constants' -import { ChatItem, ChatItemButton, ChatItemFormItem, DetailedList, MynahUIDataModel } from '@aws/mynah-ui' +import { + ChatItem, + ChatItemButton, + ChatItemContent, + ChatItemFormItem, + MynahIconsType, + DetailedList, + MynahUIDataModel, + MynahIcons, + Status, +} from '@aws/mynah-ui' import { Database } from '../../../../shared/db/chatDb/chatDb' import { TabType } from '../../../../amazonq/webview/ui/storages/tabsStorage' +import { ToolType, ToolUtils } from '../../../tools/toolUtils' +import { ChatStream } from '../../../tools/chatStream' +import path from 'path' +import { CommandValidation } from '../../../tools/executeBash' +import { extractErrorInfo } from '../../../../shared/utilities/messageUtil' +import { noWriteTools, tools } from '../../../constants' +import { Change } from 'diff' +import { FsWriteParams } from '../../../tools/fsWrite' +import { AsyncEventProgressMessage } from '../../../../amazonq/commons/connector/connectorMessages' +import { localize } from '../../../../shared/utilities/vsCodeUtils' +import { getDiffLinesFromChanges } from '../../../../shared/utilities/diffUtils' +import { FsReadParams } from '../../../tools/fsRead' +import { ListDirectoryParams } from '../../../tools/listDirectory' export type StaticTextResponseType = 'quick-action-help' | 'onboarding-help' | 'transform' | 'help' @@ -77,7 +103,30 @@ export class Messenger { ) } - public sendInitalStream( + public sendInitalStream(tabID: string, triggerID: string) { + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message: '', + messageType: 'answer-stream', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: triggerID, + userIntent: undefined, + codeBlockLanguage: undefined, + contextList: undefined, + buttons: undefined, + fileList: undefined, + canBeVoted: false, + }, + tabID + ) + ) + } + + public sendContextMessage( tabID: string, triggerID: string, mergedRelevantDocuments: DocumentReference[] | undefined @@ -91,15 +140,22 @@ export class Messenger { followUpsHeader: undefined, relatedSuggestions: undefined, triggerID, - messageID: triggerID, + messageID: '', userIntent: undefined, codeBlockLanguage: undefined, contextList: mergedRelevantDocuments, + title: '', + rootFolderTitle: 'Context', + buttons: undefined, + fileList: undefined, + canBeVoted: false, + padding: false, }, tabID ) ) } + /** * Tries to calculate the total number of code blocks. * NOTES: @@ -141,6 +197,8 @@ export class Messenger { let followUps: FollowUp[] = [] let relatedSuggestions: Suggestion[] = [] let codeBlockLanguage: string = 'plaintext' + let toolUseInput = '' + const toolUse: ToolUse = { toolUseId: undefined, name: undefined, input: undefined } if (response.message === undefined) { throw new ToolkitError( @@ -168,7 +226,7 @@ export class Messenger { }) const eventCounts = new Map() - waitUntil( + await waitUntil( async () => { for await (const chatEvent of response.message!) { if (cancelToken.isCancellationRequested) { @@ -201,6 +259,145 @@ export class Messenger { ] } + const cwChatEvent: cwChatResponseStream = chatEvent + if (cwChatEvent.toolUseEvent?.input !== undefined && cwChatEvent.toolUseEvent.input.length > 0) { + toolUseInput += cwChatEvent.toolUseEvent.input + } + + if (cwChatEvent.toolUseEvent?.stop) { + toolUse.toolUseId = cwChatEvent.toolUseEvent.toolUseId ?? '' + toolUse.name = cwChatEvent.toolUseEvent.name ?? '' + try { + try { + toolUse.input = JSON.parse(toolUseInput) + } catch (error: any) { + getLogger().error(`JSON parse error for toolUseInput: ${toolUseInput}`) + // set toolUse.input to be empty valid json object + toolUse.input = {} + error.message = `Tool input has invalid JSON format: ${error.message}` + // throw it out to allow the error to be handled in the catch block + throw error + } + const availableToolsNames = (session.pairProgrammingModeOn ? tools : noWriteTools).map( + (item) => item.toolSpecification?.name + ) + if (!availableToolsNames.includes(toolUse.name)) { + throw new Error(`Tool ${toolUse.name} is not available in the current mode`) + } + const tool = ToolUtils.tryFromToolUse(toolUse) + if ('type' in tool) { + let changeList: Change[] | undefined = undefined + let messageIdToUpdate: string | undefined = undefined + const isReadOrList: boolean = [ToolType.FsRead, ToolType.ListDirectory].includes( + tool.type + ) + if (tool.type === ToolType.FsWrite) { + session.setShowDiffOnFileWrite(true) + changeList = await tool.tool.getDiffChanges() + } + if (tool.type === ToolType.FsRead) { + messageIdToUpdate = session.messageIdToUpdate + const input = toolUse.input as unknown as FsReadParams + // Check if this file path is already in the readFiles list + const isFileAlreadyRead = session.readFiles.some( + (file) => file.relativeFilePath === input.path + ) + if (!isFileAlreadyRead) { + session.addToReadFiles({ + relativeFilePath: input?.path, + lineRanges: [{ first: -1, second: -1 }], + }) + } + } else if (tool.type === ToolType.ListDirectory) { + messageIdToUpdate = session.messageIdToUpdateListDirectory + const input = toolUse.input as unknown as ListDirectoryParams + // Check if this folder is already in the readFolders list + const isFolderAlreadyRead = session.readFolders.some( + (folder) => folder.relativeFilePath === input.path + ) + if (!isFolderAlreadyRead) { + session.setReadFolders({ + relativeFilePath: input?.path, + lineRanges: [{ first: -1, second: -1 }], + }) + } + } + const validation = ToolUtils.requiresAcceptance(tool) + const chatStream = new ChatStream( + this, + tabID, + triggerID, + toolUse, + session, + messageIdToUpdate, + true, + validation, + isReadOrList, + changeList + ) + await ToolUtils.queueDescription(tool, chatStream) + if (session.messageIdToUpdate === undefined && tool.type === ToolType.FsRead) { + // Store the first messageId in a chain of tool uses + session.setMessageIdToUpdate(toolUse.toolUseId) + } + if ( + session.messageIdToUpdateListDirectory === undefined && + tool.type === ToolType.ListDirectory + ) { + session.setMessageIdToUpdateListDirectory(toolUse.toolUseId) + } + getLogger().debug( + `SetToolUseWithError: ${toolUse.name}:${toolUse.toolUseId} with no error` + ) + session.setToolUseWithError({ toolUse, error: undefined }) + if (!validation.requiresAcceptance) { + // Need separate id for read tool and safe bash command execution as 'run-shell-command' id is required to state in cwChatConnector.ts which will impact generic tool execution. + if (tool.type === ToolType.ExecuteBash) { + this.dispatcher.sendCustomFormActionMessage( + new CustomFormActionMessage(tabID, { + id: 'run-shell-command', + }) + ) + } else { + this.dispatcher.sendCustomFormActionMessage( + new CustomFormActionMessage(tabID, { + id: 'generic-tool-execution', + }) + ) + } + } else { + if (tool.type === ToolType.ExecuteBash) { + this.telemetryHelper.recordInteractionWithAgenticChat( + AgenticChatInteractionType.GeneratedCommand, + { tabID } + ) + } + } + + if (tool.type === ToolType.FsWrite) { + this.telemetryHelper.recordInteractionWithAgenticChat( + AgenticChatInteractionType.GeneratedDiff, + { tabID } + ) + } + } else { + throw new Error('Tool not found') + } + } catch (error: any) { + getLogger().error( + `toolUseId: ${toolUse.toolUseId}, toolUseName: ${toolUse.name}, error: ${error}` + ) + session.setToolUseWithError({ toolUse, error }) + // trigger processToolUseMessage to handle the error + this.dispatcher.sendCustomFormActionMessage( + new CustomFormActionMessage(tabID, { + id: 'generic-tool-execution', + }) + ) + } + // TODO: Add a spinner component for fsWrite, previous implementation is causing lag in mynah UX. + } + if ( chatEvent.assistantResponseEvent?.content !== undefined && chatEvent.assistantResponseEvent.content.length > 0 @@ -257,29 +454,24 @@ export class Messenger { } return true }, - { timeout: 60000, truthy: true } + { timeout: 600000, truthy: true } ) .catch((error: any) => { - let errorMessage = 'Error reading chat stream.' - let statusCode = undefined - let requestID = undefined - - if (error instanceof CodeWhispererStreamingServiceException) { - errorMessage = error.message - statusCode = getHttpStatusCode(error) ?? 0 - requestID = getRequestId(error) - } - + const errorInfo = extractErrorInfo(error) this.showChatExceptionMessage( - { errorMessage, statusCode: statusCode?.toString(), sessionID: undefined }, + { + errorMessage: errorInfo.errorMessage, + statusCode: errorInfo.statusCode?.toString(), + sessionID: undefined, + }, tabID, - requestID + errorInfo.requestId ) - getLogger().error(`error: ${errorMessage} tabID: ${tabID} requestID: ${requestID}`) + getLogger().error(`error: ${errorInfo.errorMessage} tabID: ${tabID} requestID: ${errorInfo.requestId}`) followUps = [] relatedSuggestions = [] - this.telemetryHelper.recordMessageResponseError(triggerPayload, tabID, statusCode ?? 0) + this.telemetryHelper.recordMessageResponseError(triggerPayload, tabID, errorInfo.statusCode ?? 0) }) .finally(async () => { if (session.sessionIdentifier) { @@ -288,6 +480,11 @@ export class Messenger { type: 'answer' as any, codeReference: codeReference as any, relatedContent: { title: 'Sources', content: relatedSuggestions as any }, + messageId: messageID, + toolUses: + toolUse && toolUse.input !== undefined && toolUse.input !== '' + ? [{ ...toolUse }] + : undefined, }) } if ( @@ -336,11 +533,16 @@ export class Messenger { ) } + const agenticLoopEnded = !eventCounts.has('toolUseEvent') + if (agenticLoopEnded) { + // Reset context for the next request + session.setContext(undefined) + } this.dispatcher.sendChatMessage( new ChatMessage( { message: undefined, - messageType: 'answer', + messageType: agenticLoopEnded ? 'answer' : 'answer-stream', followUps: followUps, followUpsHeader: undefined, relatedSuggestions: undefined, @@ -376,18 +578,220 @@ export class Messenger { }) } - public sendErrorMessage(errorMessage: string | undefined, tabID: string, requestID: string | undefined) { + public sendInitialToolMessage(tabID: string, triggerID: string, toolUseId: string | undefined) { + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message: '', + messageType: 'answer', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: toolUseId ?? 'toolUse', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList: undefined, + }, + tabID + ) + ) + } + + public sendErrorMessage( + errorMessage: string | undefined, + tabID: string, + requestID: string | undefined, + statusCode?: number + ) { this.showChatExceptionMessage( { errorMessage: errorMessage, sessionID: undefined, - statusCode: undefined, + statusCode: statusCode?.toString(), }, tabID, requestID ) } + private sendReadAndListDirToolMessage( + toolUse: ToolUse, + session: ChatSession, + tabID: string, + triggerID: string, + messageIdToUpdate?: string + ) { + const contextList = toolUse.name === ToolType.ListDirectory ? session.readFolders : session.readFiles + const isFileRead = toolUse.name === ToolType.FsRead + const items = isFileRead ? session.readFiles : session.readFolders + const itemCount = items.length + + const title = + itemCount < 1 + ? 'Gathering context' + : isFileRead + ? `${itemCount} file${itemCount > 1 ? 's' : ''} read` + : `${itemCount} ${itemCount === 1 ? 'directory' : 'directories'} listed` + + this.dispatcher.sendToolMessage( + new ToolMessage( + { + message: '', + messageType: 'answer-part', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: messageIdToUpdate ?? toolUse?.toolUseId ?? '', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList, + canBeVoted: false, + buttons: undefined, + fullWidth: false, + padding: false, + codeBlockActions: undefined, + rootFolderTitle: title, + }, + tabID + ) + ) + } + + public sendPartialToolLog( + message: string, + tabID: string, + triggerID: string, + toolUse: ToolUse | undefined, + session: ChatSession, + messageIdToUpdate: string | undefined, + validation: CommandValidation, + changeList?: Change[] + ) { + // Handle read tool and list directory messages + if (toolUse?.name === ToolType.FsRead || toolUse?.name === ToolType.ListDirectory) { + return this.sendReadAndListDirToolMessage(toolUse, session, tabID, triggerID, messageIdToUpdate) + } + + // Handle file write tool, execute bash tool and bash command output log messages + const buttons: ChatItemButton[] = [] + let header: ChatItemHeader | undefined = undefined + if (toolUse?.name === ToolType.ExecuteBash && message.startsWith('```shell')) { + if (validation.requiresAcceptance) { + const buttons: ChatItemButton[] = [ + { + id: 'reject-shell-command', + text: localize('AWS.generic.reject', 'Reject'), + status: 'clear', + icon: 'cancel' as MynahIconsType, + }, + { + id: 'run-shell-command', + text: localize('AWS.generic.run', 'Run'), + status: 'clear', + icon: 'play' as MynahIconsType, + }, + ] + header = { + body: 'shell', + buttons, + } + } + if (validation.warning) { + message = validation.warning + message + } + } else if (toolUse?.name === ToolType.FsWrite) { + const input = toolUse.input as unknown as FsWriteParams + const fileName = path.basename(input.path) + const changes = getDiffLinesFromChanges(changeList) + const fileList: ChatItemContent['fileList'] = { + fileTreeTitle: '', + hideFileCount: true, + filePaths: [fileName], + details: { + [fileName]: { + // eslint-disable-next-line unicorn/no-null + icon: null, + changes: changes, + }, + }, + } + const buttons: ChatItemButton[] = [ + { + id: 'reject-code-diff', + status: 'clear', + icon: 'cancel' as MynahIconsType, + }, + ] + const status: { + icon?: MynahIcons | MynahIconsType + status?: { + status?: Status + icon?: MynahIcons | MynahIconsType + text?: string + } + } = { + status: { + text: 'Accepted', + status: 'success', + }, + } + header = { + buttons, + ...status, + fileList, + } + } else if (toolUse?.name === ToolType.ListDirectory || toolUse?.name === ToolType.FsRead) { + if (validation.requiresAcceptance) { + const buttons: ChatItemButton[] = [ + { + id: 'confirm-tool-use', + text: localize('AWS.generic.run', 'Run'), + status: 'clear', + icon: 'play' as MynahIconsType, + }, + { + id: 'reject-tool-use', + text: localize('AWS.generic.reject', 'Reject'), + status: 'clear', + icon: 'cancel' as MynahIconsType, + }, + ] + header = { + body: 'shell', + buttons, + } + } + } + + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message: message, + messageType: 'answer-part', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: toolUse?.toolUseId ?? '', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList: undefined, + title: undefined, + canBeVoted: false, + buttons, + fullWidth: true, + padding: false, + header, + // eslint-disable-next-line unicorn/no-null + codeBlockActions: { 'insert-to-cursor': null, copy: null }, + }, + tabID + ) + ) + } + private editorContextMenuCommandVerbs: Map = new Map([ ['aws.amazonq.explainCode', 'Explain'], ['aws.amazonq.explainIssue', 'Explain'], @@ -448,6 +852,7 @@ export class Messenger { userIntent: undefined, codeBlockLanguage: undefined, contextList: undefined, + title: undefined, }, tabID ) @@ -506,6 +911,20 @@ export class Messenger { } private showChatExceptionMessage(e: ChatException, tabID: string, requestID: string | undefined) { + const title = 'An error occurred while processing your request.' + // TODO: once the server sends the correct exception back, fix this + if (e.statusCode && e.statusCode === '500') { + // Send throttling message + this.dispatcher.sendErrorMessage( + new ErrorMessage( + title, + 'We are experiencing heavy traffic, please try again shortly.'.trimEnd().trimStart(), + tabID + ) + ) + return + } + let message = 'This error is reported to the team automatically. We will attempt to fix it as soon as possible.' if (e.errorMessage !== undefined) { message += `\n\nDetails: ${e.errorMessage}` @@ -521,9 +940,7 @@ export class Messenger { message += `\n\nRequest ID: ${requestID}` } - this.dispatcher.sendErrorMessage( - new ErrorMessage('An error occurred while processing your request.', message.trimEnd().trimStart(), tabID) - ) + this.dispatcher.sendErrorMessage(new ErrorMessage(title, message.trimEnd().trimStart(), tabID)) } public sendOpenSettingsMessage(triggerId: string, tabID: string) { @@ -569,4 +986,52 @@ export class Messenger { new ShowCustomFormMessage(tabID, formItems, buttons, title, description) ) } + + public sendAsyncEventProgress(tabID: string, inProgress: boolean, message: string | undefined) { + this.dispatcher.sendAsyncEventProgress(new AsyncEventProgressMessage(tabID, 'CWChat', inProgress, message)) + } + + public sendEmptyMessage( + tabID: string, + triggerId: string, + mergedRelevantDocuments: DocumentReference[] | undefined + ) { + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message: '', + messageType: 'answer', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID: triggerId, + messageID: '', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList: undefined, + }, + tabID + ) + ) + } + + public sendDirectiveMessage(tabID: string, triggerID: string, message: string) { + this.dispatcher.sendChatMessage( + new ChatMessage( + { + message, + messageType: 'directive', + followUps: undefined, + followUpsHeader: undefined, + relatedSuggestions: undefined, + triggerID, + messageID: '', + userIntent: undefined, + codeBlockLanguage: undefined, + contextList: undefined, + }, + tabID + ) + ) + } } diff --git a/packages/core/src/codewhispererChat/controllers/chat/model.ts b/packages/core/src/codewhispererChat/controllers/chat/model.ts index 04da124aecc..fcbe5fd4302 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/model.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/model.ts @@ -4,7 +4,13 @@ */ import * as vscode from 'vscode' -import { AdditionalContentEntry, RelevantTextDocument, UserIntent } from '@amzn/codewhisperer-streaming' +import { + AdditionalContentEntry, + Origin, + RelevantTextDocument, + ToolResult, + UserIntent, +} from '@amzn/codewhisperer-streaming' import { MatchPolicy, CodeQuery } from '../../clients/chat/v0/model' import { Selection } from 'vscode' import { TabOpenType } from '../../../amazonq/webview/ui/storages/tabsStorage' @@ -166,6 +172,13 @@ export interface FileClick { filePath: string } +export interface PromptInputOptionChange { + command: string + tabID: string + messageId: string + optionsValues: Record +} + export interface ChatItemVotedMessage { tabID: string command: string @@ -186,6 +199,18 @@ export enum ChatTriggerType { InlineChatMessage = 'InlineChatMessage', } +export enum AgenticChatInteractionType { + RejectDiff = 'RejectDiff', + GeneratedDiff = 'GeneratedDiff', + RunCommand = 'RunCommand', + GeneratedCommand = 'GeneratedCommand', + StopChat = 'StopChat', +} + +export interface AcceptResponseMessage { + tabID: string +} + export interface TriggerPayload { readonly query: string | undefined readonly codeSelection: Selection | undefined @@ -209,6 +234,9 @@ export interface TriggerPayload { traceId?: string contextLengths: ContextLengths workspaceRulesCount?: number + toolResults?: ToolResult[] + origin?: Origin + pairProgrammingModeOn?: boolean history?: Message[] } diff --git a/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts b/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts index f2c447500da..b524770ff53 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts @@ -30,6 +30,9 @@ import { TriggerPayload, AdditionalContextLengths, AdditionalContextInfo, + StopResponseMessage, + AgenticChatInteractionType, + AcceptResponseMessage, } from './model' import { TriggerEvent, TriggerEventsStorage } from '../../storages/triggerEvents' import globals from '../../../shared/extensionGlobals' @@ -44,6 +47,7 @@ import { undefinedIfEmpty } from '../../../shared/utilities/textUtilities' import { AdditionalContextPrompt } from '../../../amazonq/lsp/types' import { getUserPromptsDirectory, promptFileExtension } from '../../constants' import { isInDirectory } from '../../../shared/filesystemUtilities' +import { CustomFormActionMessage } from '../../view/connector/connector' export function logSendTelemetryEventFailure(error: any) { let requestId: string | undefined @@ -213,6 +217,19 @@ export class CWCTelemetryHelper { telemetry.feedback_result.emit({ result: feedbackResult }) } + public recordInteractionWithAgenticChat( + interactionType: AgenticChatInteractionType, + message: AcceptResponseMessage | CustomFormActionMessage | StopResponseMessage + ) { + telemetry.amazonq_interactWithAgenticChat.emit({ + cwsprAgenticChatInteractionType: interactionType, + result: 'Succeeded', + cwsprChatConversationId: this.getConversationId(message.tabID ?? '') ?? '', + cwsprChatConversationType: 'Chat', + credentialStartUrl: AuthUtil.instance.startUrl, + }) + } + public recordInteractWithMessage( message: | AcceptDiff diff --git a/packages/core/src/codewhispererChat/index.ts b/packages/core/src/codewhispererChat/index.ts index b47115fbc4a..d21dca37e0e 100644 --- a/packages/core/src/codewhispererChat/index.ts +++ b/packages/core/src/codewhispererChat/index.ts @@ -15,3 +15,4 @@ export { ChatSessionStorage } from './storages/chatSession' export { TriggerEventsStorage } from './storages/triggerEvents' export { ReferenceLogController } from './view/messages/referenceLogController' export { extractLanguageNameFromFile } from './editor/context/file/languages' +export { defaultContextLengths } from './constants' diff --git a/packages/core/src/codewhispererChat/storages/chatSession.ts b/packages/core/src/codewhispererChat/storages/chatSession.ts index 378d84e4237..d99f6e340e9 100644 --- a/packages/core/src/codewhispererChat/storages/chatSession.ts +++ b/packages/core/src/codewhispererChat/storages/chatSession.ts @@ -7,6 +7,7 @@ import { ChatSession } from '../clients/chat/v0/chat' export class ChatSessionStorage { private sessions: Map = new Map() + private agentLoopInProgress: Map = new Map() public getSession(tabID: string): ChatSession { const sessionFromStorage = this.sessions.get(tabID) @@ -22,6 +23,25 @@ export class ChatSessionStorage { public deleteSession(tabID: string) { this.sessions.delete(tabID) + this.agentLoopInProgress.delete(tabID) + } + + /** + * Check if agent loop is in progress for a specific tab + * @param tabID The tab ID to check + * @returns True if agent loop is in progress, false otherwise + */ + public isAgentLoopInProgress(tabID: string): boolean { + return this.agentLoopInProgress.get(tabID) === true + } + + /** + * Set agent loop in progress state for a specific tab + * @param tabID The tab ID to set state for + * @param inProgress Whether the agent loop is in progress + */ + public setAgentLoopInProgress(tabID: string, inProgress: boolean): void { + this.agentLoopInProgress.set(tabID, inProgress) } public deleteAllSessions() { diff --git a/packages/core/src/codewhispererChat/tools/chatStream.ts b/packages/core/src/codewhispererChat/tools/chatStream.ts new file mode 100644 index 00000000000..a51aa97aa00 --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/chatStream.ts @@ -0,0 +1,80 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Writable } from 'stream' +import { getLogger } from '../../shared/logger/logger' +import { Messenger } from '../controllers/chat/messenger/messenger' +import { ToolUse } from '@amzn/codewhisperer-streaming' +import { CommandValidation } from './executeBash' +import { Change } from 'diff' +import { ChatSession } from '../clients/chat/v0/chat' +import { i18n } from '../../shared/i18n-helper' + +/** + * A writable stream that feeds each chunk/line to the chat UI. + * Used for streaming tool output (like bash execution) to the chat interface. + */ +export class ChatStream extends Writable { + private accumulatedLogs = '' + + public constructor( + private readonly messenger: Messenger, + private readonly tabID: string, + private readonly triggerID: string, + private readonly toolUse: ToolUse | undefined, + private readonly session: ChatSession, + private readonly messageIdToUpdate: string | undefined, + // emitEvent decides to show the streaming message or read/list directory tool message to the user. + private readonly emitEvent: boolean, + private readonly validation: CommandValidation, + private readonly isReadorList: boolean, + private readonly changeList?: Change[], + private readonly logger = getLogger('chatStream') + ) { + super() + this.logger.debug( + `ChatStream created for tabID: ${tabID}, triggerID: ${triggerID}, readFiles: ${session.readFiles}, emitEvent to mynahUI: ${emitEvent}` + ) + if (!emitEvent) { + return + } + if (validation.requiresAcceptance) { + this.messenger.sendDirectiveMessage( + tabID, + triggerID, + i18n('AWS.amazonq.chat.directive.runCommandToProceed') + ) + } + // For FsRead and ListDirectory tools If messageIdToUpdate is undefined, we need to first create an empty message with messageId so it can be updated later + if (isReadorList && !messageIdToUpdate) { + this.messenger.sendInitialToolMessage(tabID, triggerID, toolUse?.toolUseId) + } else { + this.messenger.sendInitalStream(tabID, triggerID) + } + } + + override _write(chunk: Buffer, encoding: BufferEncoding, callback: (error?: Error | null) => void): void { + const text = chunk.toString() + this.accumulatedLogs += text + this.logger.debug( + `ChatStream received chunk: ${text}, emitEvent to mynahUI: ${this.emitEvent}, isReadorList tool: ${this.isReadorList}` + ) + this.messenger.sendPartialToolLog( + this.accumulatedLogs, + this.tabID, + this.triggerID, + this.toolUse, + this.session, + this.messageIdToUpdate, + this.validation, + this.changeList + ) + callback() + } + + override _final(callback: (error?: Error | null) => void): void { + callback() + } +} diff --git a/packages/core/src/codewhispererChat/tools/executeBash.ts b/packages/core/src/codewhispererChat/tools/executeBash.ts new file mode 100644 index 00000000000..4e9b946c448 --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/executeBash.ts @@ -0,0 +1,402 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Writable } from 'stream' +import { getLogger } from '../../shared/logger/logger' +import { fs } from '../../shared/fs/fs' +import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils' +import { InvokeOutput, OutputKind, sanitizePath } from './toolShared' +import { split } from 'shlex' +import path from 'path' +import * as vscode from 'vscode' +import { isInDirectory } from '../../shared/filesystemUtilities' + +export enum CommandCategory { + ReadOnly, + Mutate, + Destructive, +} + +export const splitOperators = new Set(['|', '&&', '||', '>']) +export const splitOperatorsArray = Array.from(splitOperators) +export const commandCategories = new Map([ + // ReadOnly commands + ['ls', CommandCategory.ReadOnly], + ['cat', CommandCategory.ReadOnly], + ['bat', CommandCategory.ReadOnly], + ['pwd', CommandCategory.ReadOnly], + ['echo', CommandCategory.ReadOnly], + ['file', CommandCategory.ReadOnly], + ['less', CommandCategory.ReadOnly], + ['more', CommandCategory.ReadOnly], + ['tree', CommandCategory.ReadOnly], + ['find', CommandCategory.ReadOnly], + ['top', CommandCategory.ReadOnly], + ['htop', CommandCategory.ReadOnly], + ['ps', CommandCategory.ReadOnly], + ['df', CommandCategory.ReadOnly], + ['du', CommandCategory.ReadOnly], + ['free', CommandCategory.ReadOnly], + ['uname', CommandCategory.ReadOnly], + ['date', CommandCategory.ReadOnly], + ['whoami', CommandCategory.ReadOnly], + ['which', CommandCategory.ReadOnly], + ['ping', CommandCategory.ReadOnly], + ['ifconfig', CommandCategory.ReadOnly], + ['ip', CommandCategory.ReadOnly], + ['netstat', CommandCategory.ReadOnly], + ['ss', CommandCategory.ReadOnly], + ['dig', CommandCategory.ReadOnly], + ['wc', CommandCategory.ReadOnly], + ['sort', CommandCategory.ReadOnly], + ['diff', CommandCategory.ReadOnly], + ['head', CommandCategory.ReadOnly], + ['tail', CommandCategory.ReadOnly], + + // Mutable commands + ['chmod', CommandCategory.Mutate], + ['curl', CommandCategory.Mutate], + ['mount', CommandCategory.Mutate], + ['umount', CommandCategory.Mutate], + ['systemctl', CommandCategory.Mutate], + ['service', CommandCategory.Mutate], + ['crontab', CommandCategory.Mutate], + ['at', CommandCategory.Mutate], + ['nc', CommandCategory.Mutate], + ['ssh', CommandCategory.Mutate], + ['scp', CommandCategory.Mutate], + ['ftp', CommandCategory.Mutate], + ['sftp', CommandCategory.Mutate], + ['rsync', CommandCategory.Mutate], + ['chroot', CommandCategory.Mutate], + ['strace', CommandCategory.Mutate], + ['gdb', CommandCategory.Mutate], + ['apt', CommandCategory.Mutate], + ['yum', CommandCategory.Mutate], + ['dnf', CommandCategory.Mutate], + ['pacman', CommandCategory.Mutate], + ['exec', CommandCategory.Mutate], + ['eval', CommandCategory.Mutate], + ['xargs', CommandCategory.Mutate], + + // Destructive commands + ['rm', CommandCategory.Destructive], + ['dd', CommandCategory.Destructive], + ['mkfs', CommandCategory.Destructive], + ['fdisk', CommandCategory.Destructive], + ['shutdown', CommandCategory.Destructive], + ['reboot', CommandCategory.Destructive], + ['poweroff', CommandCategory.Destructive], + ['sudo', CommandCategory.Destructive], + ['su', CommandCategory.Destructive], + ['useradd', CommandCategory.Destructive], + ['userdel', CommandCategory.Destructive], + ['passwd', CommandCategory.Destructive], + ['visudo', CommandCategory.Destructive], + ['insmod', CommandCategory.Destructive], + ['rmmod', CommandCategory.Destructive], + ['modprobe', CommandCategory.Destructive], + ['kill', CommandCategory.Destructive], + ['killall', CommandCategory.Destructive], + ['pkill', CommandCategory.Destructive], + ['iptables', CommandCategory.Destructive], + ['route', CommandCategory.Destructive], + ['chown', CommandCategory.Destructive], +]) +export const maxBashToolResponseSize: number = 1024 * 1024 // 1MB +export const lineCount: number = 1024 +export const destructiveCommandWarningMessage = '⚠️ WARNING: Destructive command detected:\n\n' +export const mutateCommandWarningMessage = 'Mutation command:\n\n' + +export interface ExecuteBashParams { + command: string + cwd?: string +} + +export interface CommandValidation { + requiresAcceptance: boolean + warning?: string +} + +// Interface for timestamped output chunks +interface TimestampedChunk { + timestamp: number + isStdout: boolean + content: string + isFirst: boolean +} + +export class ExecuteBash { + private readonly command: string + private readonly workingDirectory?: string + private readonly logger = getLogger('executeBash') + + constructor(params: ExecuteBashParams) { + this.command = params.command + this.workingDirectory = params.cwd ? sanitizePath(params.cwd) : fs.getUserHomeDir() + } + + public async validate(): Promise { + if (!this.command.trim()) { + throw new Error('Bash command cannot be empty.') + } + + const args = split(this.command) + if (!args || args.length === 0) { + throw new Error('No command found.') + } + + try { + await ExecuteBash.whichCommand(args[0]) + } catch { + throw new Error(`Command "${args[0]}" not found on PATH.`) + } + } + + public requiresAcceptance(): CommandValidation { + try { + const args = split(this.command) + if (!args || args.length === 0) { + return { requiresAcceptance: true } + } + + // Split commands by operators and process each segment + let currentCmd: string[] = [] + const allCommands: string[][] = [] + + for (const arg of args) { + if (splitOperators.has(arg)) { + if (currentCmd.length > 0) { + allCommands.push(currentCmd) + } + currentCmd = [] + } else if (splitOperatorsArray.some((op) => arg.includes(op))) { + return { requiresAcceptance: true } + } else { + currentCmd.push(arg) + } + } + + if (currentCmd.length > 0) { + allCommands.push(currentCmd) + } + + for (const cmdArgs of allCommands) { + if (cmdArgs.length === 0) { + return { requiresAcceptance: true } + } + + // For each command, validate arguments for path safety within workspace + for (const arg of cmdArgs) { + if (this.looksLikePath(arg)) { + // If not absolute, resolve using workingDirectory if available. + let fullPath = arg + if (!path.isAbsolute(arg) && this.workingDirectory) { + fullPath = path.join(this.workingDirectory, arg) + } + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + return { requiresAcceptance: true, warning: destructiveCommandWarningMessage } + } + const isInWorkspace = workspaceFolders.some((folder) => + isInDirectory(folder.uri.fsPath, fullPath) + ) + if (!isInWorkspace) { + return { requiresAcceptance: true, warning: destructiveCommandWarningMessage } + } + } + } + + const command = cmdArgs[0] + const category = commandCategories.get(command) + + switch (category) { + case CommandCategory.Destructive: + return { requiresAcceptance: true, warning: destructiveCommandWarningMessage } + case CommandCategory.Mutate: + return { requiresAcceptance: true, warning: mutateCommandWarningMessage } + case CommandCategory.ReadOnly: + continue + default: + return { requiresAcceptance: true } + } + } + return { requiresAcceptance: false } + } catch (error) { + this.logger.warn(`Error while checking acceptance: ${(error as Error).message}`) + return { requiresAcceptance: true } + } + } + + public async invoke(updates?: Writable): Promise { + this.logger.info(`Invoking bash command: "${this.command}" in cwd: "${this.workingDirectory}"`) + + return new Promise(async (resolve, reject) => { + this.logger.debug(`Spawning process with command: bash -c "${this.command}" (cwd=${this.workingDirectory})`) + + const stdoutBuffer: string[] = [] + const stderrBuffer: string[] = [] + + // Use a closure boolean value firstChunk and a function to get and set its value + let isFirstChunk = true + const getAndSetFirstChunk = (newValue: boolean): boolean => { + const oldValue = isFirstChunk + isFirstChunk = newValue + return oldValue + } + + // Use a queue to maintain chronological order of chunks + // This ensures that the output is processed in the exact order it was generated by the child process. + const outputQueue: TimestampedChunk[] = [] + let processingQueue = false + + // Process the queue in order + const processQueue = () => { + if (processingQueue || outputQueue.length === 0) { + return + } + + processingQueue = true + + try { + // Sort by timestamp to ensure chronological order + outputQueue.sort((a, b) => a.timestamp - b.timestamp) + + while (outputQueue.length > 0) { + const chunk = outputQueue.shift()! + ExecuteBash.handleTimestampedChunk(chunk, stdoutBuffer, stderrBuffer, updates) + } + } finally { + processingQueue = false + } + } + + const childProcessOptions: ChildProcessOptions = { + spawnOptions: { + cwd: this.workingDirectory, + stdio: ['pipe', 'pipe', 'pipe'], + }, + collect: false, + waitForStreams: true, + onStdout: async (chunk: string) => { + const isFirst = getAndSetFirstChunk(false) + const timestamp = Date.now() + outputQueue.push({ + timestamp, + isStdout: true, + content: chunk, + isFirst, + }) + processQueue() + }, + onStderr: async (chunk: string) => { + const isFirst = getAndSetFirstChunk(false) + const timestamp = Date.now() + outputQueue.push({ + timestamp, + isStdout: false, + content: chunk, + isFirst, + }) + processQueue() + }, + } + + const childProcess = new ChildProcess('bash', ['-c', this.command], childProcessOptions) + + try { + const result = await childProcess.run() + const exitStatus = result.exitCode ?? 0 + const stdout = stdoutBuffer.join('\n') + const stderr = stderrBuffer.join('\n') + const [stdoutTrunc, stdoutSuffix] = ExecuteBash.truncateSafelyWithSuffix( + stdout, + maxBashToolResponseSize / 3 + ) + const [stderrTrunc, stderrSuffix] = ExecuteBash.truncateSafelyWithSuffix( + stderr, + maxBashToolResponseSize / 3 + ) + + const outputJson = { + exitStatus: exitStatus.toString(), + stdout: stdoutTrunc + (stdoutSuffix ? ' ... truncated' : ''), + stderr: stderrTrunc + (stderrSuffix ? ' ... truncated' : ''), + } + + resolve({ + output: { + kind: OutputKind.Json, + content: outputJson, + }, + }) + } catch (err: any) { + this.logger.error(`Failed to execute bash command '${this.command}': ${err.message}`) + reject(new Error(`Failed to execute command: ${err.message}`)) + } + }) + } + + private static handleTimestampedChunk( + chunk: TimestampedChunk, + stdoutBuffer: string[], + stderrBuffer: string[], + updates?: Writable + ): void { + const buffer = chunk.isStdout ? stdoutBuffer : stderrBuffer + const content = chunk.isFirst ? '```console\n' + chunk.content : chunk.content + ExecuteBash.handleChunk(content, buffer, updates) + } + + private static handleChunk(chunk: string, buffer: string[], updates?: Writable) { + try { + updates?.write(chunk) + const lines = chunk.split(/\r?\n/) + for (const line of lines) { + buffer.push(line) + if (buffer.length > lineCount) { + buffer.shift() + } + } + } catch (error) { + // Log the error but don't let it crash the process + throw new Error('Error handling output chunk') + } + } + + private static truncateSafelyWithSuffix(str: string, maxLength: number): [string, boolean] { + if (str.length > maxLength) { + return [str.substring(0, maxLength), true] + } + return [str, false] + } + + private static async whichCommand(cmd: string): Promise { + const cp = new ChildProcess('which', [cmd], { + collect: true, + waitForStreams: true, + }) + const result = await cp.run() + + if (result.exitCode !== 0) { + throw new Error(`Command "${cmd}" not found on PATH.`) + } + + const output = result.stdout.trim() + if (!output) { + throw new Error(`Command "${cmd}" found but 'which' returned empty output.`) + } + return output + } + + public queueDescription(updates: Writable): void { + updates.write('```shell\n' + this.command + '\n```') + updates.end() + } + + private looksLikePath(arg: string): boolean { + return arg.startsWith('/') || arg.startsWith('./') || arg.startsWith('../') + } +} diff --git a/packages/core/src/codewhispererChat/tools/fsRead.ts b/packages/core/src/codewhispererChat/tools/fsRead.ts new file mode 100644 index 00000000000..5b0011a2167 --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/fsRead.ts @@ -0,0 +1,128 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import { getLogger } from '../../shared/logger/logger' +import fs from '../../shared/fs/fs' +import { Writable } from 'stream' +import { InvokeOutput, OutputKind, sanitizePath, CommandValidation } from './toolShared' +import { isInDirectory } from '../../shared/filesystemUtilities' + +export interface FsReadParams { + path: string + readRange?: number[] +} + +export class FsRead { + private fsPath: string + private readonly readRange?: number[] + private readonly logger = getLogger('fsRead') + + constructor(params: FsReadParams) { + this.fsPath = params.path + this.readRange = params.readRange + } + + public async validate(): Promise { + this.logger.debug(`Validating fsPath: ${this.fsPath}`) + if (!this.fsPath || this.fsPath.trim().length === 0) { + throw new Error('Path cannot be empty.') + } + + const sanitized = sanitizePath(this.fsPath) + this.fsPath = sanitized + + const fileUri = vscode.Uri.file(this.fsPath) + let fileExists: boolean + try { + fileExists = await fs.existsFile(fileUri) + if (!fileExists) { + throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed.`) + } + } catch (err) { + throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed. (${err})`) + } + + this.logger.debug(`Validation succeeded for path: ${this.fsPath}`) + } + + public queueDescription(updates: Writable): void { + updates.write('') + updates.end() + } + + public requiresAcceptance(): CommandValidation { + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + return { requiresAcceptance: true } + } + const isInWorkspace = workspaceFolders.some((folder) => isInDirectory(folder.uri.fsPath, this.fsPath)) + if (!isInWorkspace) { + return { requiresAcceptance: true } + } + return { requiresAcceptance: false } + } + + public async invoke(updates?: Writable): Promise { + try { + const fileUri = vscode.Uri.file(this.fsPath) + + const fileContents = await this.readFile(fileUri) + this.logger.info(`Read file: ${this.fsPath}, size: ${fileContents.length}`) + return this.handleFileRange(fileContents) + } catch (error: any) { + this.logger.error(`Failed to read "${this.fsPath}": ${error.message || error}`) + throw new Error(`Failed to read "${this.fsPath}": ${error.message || error}`) + } + } + + private async readFile(fileUri: vscode.Uri): Promise { + this.logger.info(`Reading file: ${fileUri.fsPath}`) + return await fs.readFileText(fileUri) + } + + private handleFileRange(fullText: string): InvokeOutput { + if (!this.readRange || this.readRange.length === 0) { + this.logger.info('No range provided. returning entire file.') + return this.createOutput(fullText) + } + + const lines = fullText.split('\n') + const [start, end] = this.parseLineRange(lines.length, this.readRange) + if (start > end) { + this.logger.error(`Invalid range: ${this.readRange.join('-')}`) + return this.createOutput('') + } + + this.logger.info(`Reading file: ${this.fsPath}, lines ${start + 1}-${end + 1}`) + const slice = lines.slice(start, end + 1).join('\n') + return this.createOutput(slice) + } + + private parseLineRange(lineCount: number, range: number[]): [number, number] { + const startIdx = range[0] + let endIdx = range.length >= 2 ? range[1] : undefined + + if (endIdx === undefined) { + endIdx = -1 + } + + const convert = (i: number): number => { + return i < 0 ? lineCount + i : i - 1 + } + + const finalStart = Math.max(0, Math.min(lineCount - 1, convert(startIdx))) + const finalEnd = Math.max(0, Math.min(lineCount - 1, convert(endIdx))) + return [finalStart, finalEnd] + } + + private createOutput(content: string): InvokeOutput { + return { + output: { + kind: OutputKind.Text, + content: content, + }, + } + } +} diff --git a/packages/core/src/codewhispererChat/tools/fsWrite.ts b/packages/core/src/codewhispererChat/tools/fsWrite.ts new file mode 100644 index 00000000000..dff68d4f425 --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/fsWrite.ts @@ -0,0 +1,236 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InvokeOutput, OutputKind, sanitizePath } from './toolShared' +import { getLogger } from '../../shared/logger/logger' +import vscode from 'vscode' +import { fs } from '../../shared/fs/fs' +import { Writable } from 'stream' +import { Change, diffLines } from 'diff' + +interface BaseParams { + path: string +} + +export interface CreateParams extends BaseParams { + command: 'create' + fileText?: string + newStr?: string +} + +export interface StrReplaceParams extends BaseParams { + command: 'strReplace' + oldStr: string + newStr: string +} + +export interface InsertParams extends BaseParams { + command: 'insert' + insertLine: number + newStr: string +} + +export interface AppendParams extends BaseParams { + command: 'append' + newStr: string +} + +export type FsWriteParams = CreateParams | StrReplaceParams | InsertParams | AppendParams + +export interface FsWriteBackup { + filePath: string + content: string + isNew: boolean +} + +export class FsWrite { + private readonly logger = getLogger('fsWrite') + + constructor(private readonly params: FsWriteParams) {} + + public async invoke(updates?: Writable): Promise { + const sanitizedPath = sanitizePath(this.params.path) + + switch (this.params.command) { + case 'create': + await this.handleCreate(this.params, sanitizedPath) + break + case 'strReplace': + await this.handleStrReplace(this.params, sanitizedPath) + break + case 'insert': + await this.handleInsert(this.params, sanitizedPath) + break + case 'append': + await this.handleAppend(this.params, sanitizedPath) + break + } + + return { + output: { + kind: OutputKind.Text, + content: '', + }, + } + } + + public async queueDescription(updates: Writable): Promise { + // Write an empty string because FsWrite should only show a chat message with header + updates.write(' ') + updates.end() + } + + public async getDiffChanges(): Promise { + let newContent + const { filePath: sanitizedPath, content: oldContent } = await this.getBackup() + switch (this.params.command) { + case 'create': + newContent = this.getCreateCommandText(this.params) + break + case 'strReplace': + newContent = await this.getStrReplaceContent(this.params, sanitizedPath) + break + case 'insert': + newContent = await this.getInsertContent(this.params, sanitizedPath) + break + case 'append': + newContent = await this.getAppendContent(this.params, sanitizedPath) + break + } + return diffLines(oldContent, newContent) + } + + public async getBackup(): Promise { + const sanitizedPath = sanitizePath(this.params.path) + let oldContent + let isNew + try { + oldContent = await fs.readFileText(sanitizedPath) + isNew = false + } catch (err) { + oldContent = '' + isNew = true + } + return { filePath: sanitizedPath, content: oldContent, isNew } + } + + public async validate(): Promise { + switch (this.params.command) { + case 'create': + if (!this.params.path) { + throw new Error('Path must not be empty') + } + break + case 'strReplace': + case 'insert': { + const fileExists = await fs.existsFile(this.params.path) + if (!fileExists) { + throw new Error('The provided path must exist in order to replace or insert contents into it') + } + break + } + case 'append': + if (!this.params.path) { + throw new Error('Path must not be empty') + } + if (!this.params.newStr) { + throw new Error('Content to append must not be empty') + } + break + } + } + + private async handleCreate(params: CreateParams, sanitizedPath: string): Promise { + const content = this.getCreateCommandText(params) + + const fileExists = await fs.existsFile(sanitizedPath) + const actionType = fileExists ? 'Replacing' : 'Creating' + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `${actionType}: ${sanitizedPath}`, + cancellable: false, + }, + async () => { + await fs.writeFile(sanitizedPath, content) + } + ) + } + + private async handleStrReplace(params: StrReplaceParams, sanitizedPath: string): Promise { + const newContent = await this.getStrReplaceContent(params, sanitizedPath) + await fs.writeFile(sanitizedPath, newContent) + } + + private async getStrReplaceContent(params: StrReplaceParams, sanitizedPath: string): Promise { + const fileContent = await fs.readFileText(sanitizedPath) + + const matches = [...fileContent.matchAll(new RegExp(this.escapeRegExp(params.oldStr), 'g'))] + + if (matches.length === 0) { + throw new Error(`No occurrences of "${params.oldStr}" were found`) + } + if (matches.length > 1) { + throw new Error(`${matches.length} occurrences of oldStr were found when only 1 is expected`) + } + + return fileContent.replace(params.oldStr, params.newStr) + } + + private async handleInsert(params: InsertParams, sanitizedPath: string): Promise { + const newContent = await this.getInsertContent(params, sanitizedPath) + await fs.writeFile(sanitizedPath, newContent) + } + + private async getInsertContent(params: InsertParams, sanitizedPath: string): Promise { + const fileContent = await fs.readFileText(sanitizedPath) + const lines = fileContent.split('\n') + + const numLines = lines.length + const insertLine = Math.max(0, Math.min(params.insertLine, numLines)) + + let newContent: string + if (insertLine === 0) { + newContent = params.newStr + '\n' + fileContent + } else { + newContent = [...lines.slice(0, insertLine), params.newStr, ...lines.slice(insertLine)].join('\n') + } + return newContent + } + + private async handleAppend(params: AppendParams, sanitizedPath: string): Promise { + const newContent = await this.getAppendContent(params, sanitizedPath) + await fs.writeFile(sanitizedPath, newContent) + } + + private async getAppendContent(params: AppendParams, sanitizedPath: string): Promise { + const fileContent = await fs.readFileText(sanitizedPath) + const needsNewline = fileContent.length !== 0 && !fileContent.endsWith('\n') + + let contentToAppend = params.newStr + if (needsNewline) { + contentToAppend = '\n' + contentToAppend + } + + return fileContent + contentToAppend + } + + private getCreateCommandText(params: CreateParams): string { + if (params.fileText) { + return params.fileText + } + if (params.newStr) { + this.logger.warn('Required field `fileText` is missing, use the provided `newStr` instead') + return params.newStr + } + this.logger.warn('No content provided for the create command') + return '' + } + + private escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + } +} diff --git a/packages/core/src/codewhispererChat/tools/grepSearch.ts b/packages/core/src/codewhispererChat/tools/grepSearch.ts new file mode 100644 index 00000000000..01a0750c7af --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/grepSearch.ts @@ -0,0 +1,239 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import { getLogger } from '../../shared/logger/logger' +import { sanitizePath, InvokeOutput, OutputKind } from './toolShared' +import fs from '../../shared/fs/fs' +import { Writable } from 'stream' +import { ChildProcess, ChildProcessOptions } from '../../shared/utilities/processUtils' +import { rgPath } from 'vscode-ripgrep' +import path from 'path' + +export interface GrepSearchParams { + path?: string + query: string + caseSensitive?: boolean + excludePattern?: string + includePattern?: string + explanation?: string +} + +export class GrepSearch { + private path: string + private query: string + private caseSensitive: boolean + private excludePattern?: string + private includePattern?: string + private readonly logger = getLogger('grepSearch') + + constructor(params: GrepSearchParams) { + this.path = this.getSearchDirectory(params.path) + this.query = params.query + this.caseSensitive = params.caseSensitive ?? false + this.excludePattern = params.excludePattern + this.includePattern = params.includePattern + } + + public async validate(): Promise { + if (!this.query || this.query.trim().length === 0) { + throw new Error('Grep search query cannot be empty.') + } + + if (this.path.trim().length === 0) { + throw new Error('Path cannot be empty and no workspace folder is available.') + } + + const sanitized = sanitizePath(this.path) + this.path = sanitized + + const pathUri = vscode.Uri.file(this.path) + let pathExists: boolean + try { + pathExists = await fs.existsDir(pathUri) + if (!pathExists) { + throw new Error(`Path: "${this.path}" does not exist or cannot be accessed.`) + } + } catch (err) { + throw new Error(`Path: "${this.path}" does not exist or cannot be accessed. (${err})`) + } + } + + public queueDescription(updates: Writable): void { + updates.write(`Grepping for "${this.query}" in directory: ${this.path}`) + updates.end() + } + + public async invoke(updates?: Writable): Promise { + try { + const results = await this.executeRipgrep(updates) + return this.createOutput(results) + } catch (error: any) { + this.logger.error(`Failed to search in "${this.path}": ${error.message || error}`) + throw new Error(`Failed to search in "${this.path}": ${error.message || error}`) + } + } + + private getSearchDirectory(path?: string): string { + let searchLocation = '' + if (path && path.trim().length !== 0) { + searchLocation = path + } else { + // Handle optional path parameter + // Use current workspace folder as default if path is not provided + const workspaceFolders = vscode.workspace.workspaceFolders + this.logger.info(`Using default workspace folder: ${workspaceFolders?.length}`) + if (workspaceFolders && workspaceFolders.length !== 0) { + searchLocation = workspaceFolders[0].uri.fsPath + this.logger.debug(`Using default workspace folder: ${searchLocation}`) + } + } + return searchLocation + } + + private async executeRipgrep(updates?: Writable): Promise { + return new Promise(async (resolve, reject) => { + const args: string[] = [] + + // Add search options + if (!this.caseSensitive) { + args.push('-i') // Case insensitive search + } + args.push('--line-number') // Show line numbers + + // No heading (don't group matches by file) + args.push('--no-heading') + + // Don't use color in output + args.push('--color', 'never') + + // Add include/exclude patterns + if (this.includePattern) { + // Support multiple include patterns + const patterns = this.includePattern.split(',') + for (const pattern of patterns) { + args.push('--glob', pattern.trim()) + } + } + + if (this.excludePattern) { + // Support multiple exclude patterns + const patterns = this.excludePattern.split(',') + for (const pattern of patterns) { + args.push('--glob', `!${pattern.trim()}`) + } + } + + // Add search pattern and path + args.push(this.query, this.path) + + this.logger.debug(`Executing ripgrep with args: ${args.join(' ')}`) + + const options: ChildProcessOptions = { + collect: true, + logging: 'yes', + rejectOnErrorCode: (code) => { + if (code !== 0 && code !== 1) { + this.logger.error(`Ripgrep process exited with code ${code}`) + return new Error(`Ripgrep process exited with code ${code}`) + } + return new Error() + }, + } + + try { + const rg = new ChildProcess(rgPath, args, options) + const result = await rg.run() + this.logger.info(`Executing ripgrep with exitCode: ${result.exitCode}`) + // Process the output to format with file URLs and remove matched content + const { sanitizedOutput, totalMatchCount } = this.processRipgrepOutput(result.stdout) + + // If updates is provided, write the processed output + if (updates) { + updates.write(`\n\n${totalMatchCount} matches found:\n\n`) + updates.write(sanitizedOutput) + } + + this.logger.info(`Processed ripgrep result: ${totalMatchCount} matches found`) + resolve(sanitizedOutput) + } catch (err) { + reject(err) + } + }) + } + + /** + * Process ripgrep output to: + * 1. Group results by file + * 2. Format as collapsible sections + * 3. Add file URLs for clickable links + * @returns An object containing the processed output and total match count + */ + private processRipgrepOutput(output: string): { sanitizedOutput: string; totalMatchCount: number } { + if (!output || output.trim() === '') { + return { sanitizedOutput: 'No matches found.', totalMatchCount: 0 } + } + + const lines = output.split('\n') + + // Group by file path + const fileGroups: Record = {} + let totalMatchCount = 0 + + for (const line of lines) { + if (!line || line.trim() === '') { + continue + } + + // Extract file path and line number + const parts = line.split(':') + if (parts.length < 2) { + continue + } + + const filePath = parts[0] + const lineNumber = parts[1] + // Don't include match content + + if (!fileGroups[filePath]) { + fileGroups[filePath] = [] + } + + // Create a clickable link with line number using VS Code's Uri.with() method + const uri = vscode.Uri.file(filePath) + // Use the with() method to add the line number as a fragment + const uriWithLine = uri.with({ fragment: `L${lineNumber}` }) + fileGroups[filePath].push(`- [Line ${lineNumber}](${uriWithLine.toString(true)})`) + totalMatchCount++ + } + + // Sort files by match count (most matches first) + const sortedFiles = Object.entries(fileGroups).sort((a, b) => b[1].length - a[1].length) + + // Format as collapsible sections + const sanitizedOutput = sortedFiles + .map(([filePath, matches]) => { + const fileName = path.basename(filePath) + const matchCount = matches.length + + return `
+ ${fileName} - match count: (${matchCount}) + +${matches.join('\n')} +
` + }) + .join('\n\n') + + return { sanitizedOutput, totalMatchCount } + } + + private createOutput(content: string): InvokeOutput { + return { + output: { + kind: OutputKind.Text, + content: content || 'No matches found.', + }, + } + } +} diff --git a/packages/core/src/codewhispererChat/tools/listDirectory.ts b/packages/core/src/codewhispererChat/tools/listDirectory.ts new file mode 100644 index 00000000000..a1eb6a37858 --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/listDirectory.ts @@ -0,0 +1,96 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import * as vscode from 'vscode' +import { getLogger } from '../../shared/logger/logger' +import { readDirectoryRecursively } from '../../shared/utilities/workspaceUtils' +import fs from '../../shared/fs/fs' +import { Writable } from 'stream' +import path from 'path' +import { InvokeOutput, OutputKind, sanitizePath, CommandValidation } from './toolShared' +import { isInDirectory } from '../../shared/filesystemUtilities' + +export interface ListDirectoryParams { + path: string + maxDepth?: number +} + +export class ListDirectory { + private fsPath: string + private maxDepth?: number + private readonly logger = getLogger('listDirectory') + + constructor(params: ListDirectoryParams) { + this.fsPath = params.path + this.maxDepth = params.maxDepth + } + + public async validate(): Promise { + if (!this.fsPath || this.fsPath.trim().length === 0) { + throw new Error('Path cannot be empty.') + } + if (this.maxDepth !== undefined && this.maxDepth < 0) { + throw new Error('MaxDepth cannot be negative.') + } + + const sanitized = sanitizePath(this.fsPath) + this.fsPath = sanitized + + const pathUri = vscode.Uri.file(this.fsPath) + let pathExists: boolean + try { + pathExists = await fs.existsDir(pathUri) + if (!pathExists) { + throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed.`) + } + } catch (err) { + throw new Error(`Path: "${this.fsPath}" does not exist or cannot be accessed. (${err})`) + } + } + + public queueDescription(updates: Writable): void { + const fileName = path.basename(this.fsPath) + if (this.maxDepth === undefined) { + updates.write(`Analyzing directories recursively: ${fileName}`) + } else if (this.maxDepth === 0) { + updates.write(`Analyzing directory: ${fileName}`) + } else { + const level = this.maxDepth > 1 ? 'levels' : 'level' + updates.write(`Analyzing directory: ${fileName} limited to ${this.maxDepth} subfolder ${level}`) + } + updates.end() + } + + public requiresAcceptance(): CommandValidation { + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + return { requiresAcceptance: true } + } + const isInWorkspace = workspaceFolders.some((folder) => isInDirectory(folder.uri.fsPath, this.fsPath)) + if (!isInWorkspace) { + return { requiresAcceptance: true } + } + return { requiresAcceptance: false } + } + + public async invoke(updates?: Writable): Promise { + try { + const fileUri = vscode.Uri.file(this.fsPath) + const listing = await readDirectoryRecursively(fileUri, this.maxDepth) + return this.createOutput(listing.join('\n')) + } catch (error: any) { + this.logger.error(`Failed to list directory "${this.fsPath}": ${error.message || error}`) + throw new Error(`Failed to list directory "${this.fsPath}": ${error.message || error}`) + } + } + + private createOutput(content: string): InvokeOutput { + return { + output: { + kind: OutputKind.Text, + content: content, + }, + } + } +} diff --git a/packages/core/src/codewhispererChat/tools/toolShared.ts b/packages/core/src/codewhispererChat/tools/toolShared.ts new file mode 100644 index 00000000000..d65c819567b --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/toolShared.ts @@ -0,0 +1,39 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'path' +import fs from '../../shared/fs/fs' + +export const maxToolResponseSize = 200_000 + +export enum OutputKind { + Text = 'text', + Json = 'json', +} + +export interface InvokeOutput { + output: { + kind: OutputKind + content: string | any + } +} + +export function sanitizePath(inputPath: string): string { + let sanitized = inputPath.trim() + + if (sanitized.startsWith('~')) { + sanitized = path.join(fs.getUserHomeDir(), sanitized.slice(1)) + } + + if (!path.isAbsolute(sanitized)) { + sanitized = path.resolve(sanitized) + } + return sanitized +} + +export interface CommandValidation { + requiresAcceptance: boolean + warning?: string +} diff --git a/packages/core/src/codewhispererChat/tools/toolUtils.ts b/packages/core/src/codewhispererChat/tools/toolUtils.ts new file mode 100644 index 00000000000..c6eacf9e45d --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/toolUtils.ts @@ -0,0 +1,170 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { Writable } from 'stream' +import { FsRead, FsReadParams } from './fsRead' +import { FsWrite, FsWriteParams } from './fsWrite' +import { CommandValidation, ExecuteBash, ExecuteBashParams } from './executeBash' +import { ToolResult, ToolResultContentBlock, ToolResultStatus, ToolUse } from '@amzn/codewhisperer-streaming' +import { InvokeOutput, maxToolResponseSize } from './toolShared' +import { ListDirectory, ListDirectoryParams } from './listDirectory' +import { GrepSearch, GrepSearchParams } from './grepSearch' + +export enum ToolType { + FsRead = 'fsRead', + FsWrite = 'fsWrite', + ExecuteBash = 'executeBash', + ListDirectory = 'listDirectory', + GrepSearch = 'grepSearch', +} + +export type Tool = + | { type: ToolType.FsRead; tool: FsRead } + | { type: ToolType.FsWrite; tool: FsWrite } + | { type: ToolType.ExecuteBash; tool: ExecuteBash } + | { type: ToolType.ListDirectory; tool: ListDirectory } + | { type: ToolType.GrepSearch; tool: GrepSearch } + +export class ToolUtils { + static displayName(tool: Tool): string { + switch (tool.type) { + case ToolType.FsRead: + return 'Read from filesystem' + case ToolType.FsWrite: + return 'Write to filesystem' + case ToolType.ExecuteBash: + return 'Execute shell command' + case ToolType.ListDirectory: + return 'List directory from filesystem' + case ToolType.GrepSearch: + return 'Run Fast text-based regex search' + } + } + + static requiresAcceptance(tool: Tool): CommandValidation { + switch (tool.type) { + case ToolType.FsRead: + return tool.tool.requiresAcceptance() + case ToolType.FsWrite: + return { requiresAcceptance: false } + case ToolType.ExecuteBash: + return tool.tool.requiresAcceptance() + case ToolType.ListDirectory: + return tool.tool.requiresAcceptance() + case ToolType.GrepSearch: + return { requiresAcceptance: false } + } + } + + static async invoke(tool: Tool, updates?: Writable): Promise { + switch (tool.type) { + case ToolType.FsRead: + return tool.tool.invoke(updates) + case ToolType.FsWrite: + return tool.tool.invoke(updates) + case ToolType.ExecuteBash: + return tool.tool.invoke(updates ?? undefined) + case ToolType.ListDirectory: + return tool.tool.invoke(updates) + case ToolType.GrepSearch: + return tool.tool.invoke(updates) + } + } + + static validateOutput(output: InvokeOutput): void { + if (output.output.content.length > maxToolResponseSize) { + throw Error(`Tool output exceeds maximum character limit of ${maxToolResponseSize}`) + } + } + + static async queueDescription(tool: Tool, updates: Writable): Promise { + switch (tool.type) { + case ToolType.FsRead: + tool.tool.queueDescription(updates) + break + case ToolType.FsWrite: + await tool.tool.queueDescription(updates) + break + case ToolType.ExecuteBash: + tool.tool.queueDescription(updates) + break + case ToolType.ListDirectory: + tool.tool.queueDescription(updates) + break + case ToolType.GrepSearch: + tool.tool.queueDescription(updates) + break + } + } + + static async validate(tool: Tool): Promise { + switch (tool.type) { + case ToolType.FsRead: + return tool.tool.validate() + case ToolType.FsWrite: + return tool.tool.validate() + case ToolType.ExecuteBash: + return tool.tool.validate() + case ToolType.ListDirectory: + return tool.tool.validate() + case ToolType.GrepSearch: + return tool.tool.validate() + } + } + + static tryFromToolUse(value: ToolUse): Tool | ToolResult { + const mapErr = (parseError: any): ToolResult => ({ + toolUseId: value.toolUseId, + content: [ + { + type: 'text', + text: `Failed to validate tool parameters: ${parseError}. The model has either suggested tool parameters which are incompatible with the existing tools, or has suggested one or more tool that does not exist in the list of known tools.`, + } as ToolResultContentBlock, + ], + status: ToolResultStatus.ERROR, + }) + + try { + switch (value.name) { + case ToolType.FsRead: + return { + type: ToolType.FsRead, + tool: new FsRead(value.input as unknown as FsReadParams), + } + case ToolType.FsWrite: + return { + type: ToolType.FsWrite, + tool: new FsWrite(value.input as unknown as FsWriteParams), + } + case ToolType.ExecuteBash: + return { + type: ToolType.ExecuteBash, + tool: new ExecuteBash(value.input as unknown as ExecuteBashParams), + } + case ToolType.ListDirectory: + return { + type: ToolType.ListDirectory, + tool: new ListDirectory(value.input as unknown as ListDirectoryParams), + } + case ToolType.GrepSearch: + return { + type: ToolType.GrepSearch, + tool: new GrepSearch(value.input as unknown as GrepSearchParams), + } + default: + return { + toolUseId: value.toolUseId, + content: [ + { + type: 'text', + text: `The tool, "${value.name}" is not supported by the client`, + } as ToolResultContentBlock, + ], + } + } + } catch (error) { + return mapErr(error) + } + } +} diff --git a/packages/core/src/codewhispererChat/tools/tool_index.json b/packages/core/src/codewhispererChat/tools/tool_index.json new file mode 100644 index 00000000000..e5d725c2f4b --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/tool_index.json @@ -0,0 +1,136 @@ +{ + "fsRead": { + "name": "fsRead", + "description": "A tool for reading a file.\n * This tool returns the contents of a file, and the optional `readRange` determines what range of lines will be read from the specified file.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "description": "Absolute path to a file, e.g. `/repo/file.py`.", + "type": "string" + }, + "readRange": { + "description": "Optional parameter when reading files.\n * If none is given, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[startLine, -1]` shows all lines from `startLine` to the end of the file. If the whole file is too large, try reading 4000 lines at once, for example: after reading [1, 4000], read [4000, 8000] next and repeat. You should read atleast 250 lines per invocation of the tool. In some cases, if reading a range of lines results in too many invocations instead attempt to read 4000 lines.", + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "required": ["path"] + } + }, + "fsWrite": { + "name": "fsWrite", + "description": "A tool for creating and editing a file.\n * The `create` command will override the file at `path` if it already exists as a file, and otherwise create a new file\n * The `append` command will add content to the end of an existing file, automatically adding a newline if the file doesn't end with one. The file must exist.\n Notes for using the `strReplace` command:\n * The `oldStr` parameter should match EXACTLY one or more consecutive lines from the original file. Be mindful of whitespaces!\n * If the `oldStr` parameter is not unique in the file, the replacement will not be performed. Make sure to include enough context in `oldStr` to make it unique\n * The `newStr` parameter should contain the edited lines that should replace the `oldStr`. The `insert` command will insert `newStr` after `insertLine` and place it on its own line.", + "inputSchema": { + "type": "object", + "properties": { + "explanation": { + "description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", + "type": "string" + }, + "command": { + "type": "string", + "enum": ["create", "strReplace", "insert", "append"], + "description": "The commands to run. Allowed options are: `create`, `strReplace`, `insert`, `append`." + }, + "fileText": { + "description": "Required parameter of `create` command, with the content of the file to be created.", + "type": "string" + }, + "insertLine": { + "description": "Required parameter of `insert` command. The `newStr` will be inserted AFTER the line `insertLine` of `path`.", + "type": "integer" + }, + "newStr": { + "description": "Required parameter of `strReplace` command containing the new string. Required parameter of `insert` command containing the string to insert. Required parameter of `append` command containing the content to append to the file.", + "type": "string" + }, + "oldStr": { + "description": "Required parameter of `strReplace` command containing the string in `path` to replace.", + "type": "string" + }, + "path": { + "description": "Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`.", + "type": "string" + } + }, + "required": ["command", "path"] + } + }, + "executeBash": { + "name": "executeBash", + "description": "Execute the specified bash command.", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Bash command to execute" + }, + "cwd": { + "type": "string", + "description": "Parameter to set the current working directory for the bash command." + } + }, + "required": ["command", "cwd"] + } + }, + "listDirectory": { + "name": "listDirectory", + "description": "List the contents of a directory and its subdirectories.\n * Use this tool for discovery, before using more targeted tools like fsRead.\n *Useful to try to understand the file structure before diving deeper into specific files.\n *Can be used to explore the codebase.\n *Results clearly distinguish between files, directories or symlinks with [FILE], [DIR] and [LINK] prefixes.", + "inputSchema": { + "type": "object", + "properties": { + "explanation": { + "description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", + "type": "string" + }, + "path": { + "type": "string", + "description": "Absolute path to a directory, e.g., `/repo`." + }, + "maxDepth": { + "type": "integer", + "description": "Maximum depth to traverse when listing directories. Use `0` to list only the specified directory, `1` to include immediate subdirectories, etc. If it's not provided, it will list all subdirectories recursively." + } + }, + "required": ["path"] + } + }, + "grepSearch": { + "name": "grepSearch", + "description": "Fast text-based regex search that finds exact pattern matches within files or directories, utilizing the ripgrep command for efficient searching.\\nResults will be formatted in the style of ripgrep and can be configured to include line numbers and content.\\nTo avoid overwhelming output, the results are capped at 50 matches.\\nUse the include or exclude patterns to filter the search scope by file type or specific paths.\\n\\nThis is best for finding exact text matches or regex patterns.\\nMore precise than semantic search for finding specific strings or patterns.\\nThis is preferred over semantic search when we know the exact symbol/function name/etc. to search in some set of directories/file types.", + "inputSchema": { + "type": "object", + "properties": { + "caseSensitive": { + "description": "Whether the search should be case sensitive", + "type": "boolean" + }, + "excludePattern": { + "description": "Glob pattern for files to exclude", + "type": "string" + }, + "explanation": { + "description": "One sentence explanation as to why this tool is being used, and how it contributes to the goal.", + "type": "string" + }, + "includePattern": { + "description": "Glob pattern for files to include (e.g. '*.ts' for TypeScript files)", + "type": "string" + }, + "query": { + "description": "The regex pattern to search for", + "type": "string" + }, + "path": { + "description": "Absolute path to a directory, e.g., `/repo`.", + "type": "string" + } + }, + "required": ["query"] + } + } +} diff --git a/packages/core/src/codewhispererChat/view/connector/connector.ts b/packages/core/src/codewhispererChat/view/connector/connector.ts index b9c1e067b1e..a718762d8af 100644 --- a/packages/core/src/codewhispererChat/view/connector/connector.ts +++ b/packages/core/src/codewhispererChat/view/connector/connector.ts @@ -10,13 +10,19 @@ import { AuthFollowUpType } from '../../../amazonq/auth/model' import { ChatItem, ChatItemButton, + ChatItemContent, ChatItemFormItem, + CodeBlockActions, DetailedList, DetailedListItem, + MynahIcons, + MynahIconsType, MynahUIDataModel, QuickActionCommand, + Status, } from '@aws/mynah-ui' import { DocumentReference } from '../../controllers/chat/model' +import { AsyncEventProgressMessage } from '../../../amazonq/commons/connector/connectorMessages' import { TabType } from '../../../amazonq/webview/ui/storages/tabsStorage' class UiMessage { @@ -107,7 +113,7 @@ export class SearchView extends UiMessage { override type = 'drawNewSearchViewState' } -export type ChatMessageType = 'answer-stream' | 'answer-part' | 'answer' +export type ChatMessageType = 'answer-stream' | 'answer-part' | 'answer' | 'directive' export interface CodeReference { licenseName?: string @@ -315,6 +321,17 @@ export class ContextSelectedMessage extends UiMessage { } } +export type ChatItemHeader = + | (ChatItemContent & { + icon?: MynahIcons | MynahIconsType + status?: { + status?: Status + icon?: MynahIcons | MynahIconsType + text?: string + } + }) + | null + export interface ChatMessageProps { readonly message: string | undefined readonly messageType: ChatMessageType @@ -327,6 +344,15 @@ export interface ChatMessageProps { readonly userIntent: string | undefined readonly codeBlockLanguage: string | undefined readonly contextList: DocumentReference[] | undefined + readonly title?: string + readonly buttons?: ChatItemButton[] + readonly fileList?: ChatItemContent['fileList'] + readonly canBeVoted?: boolean + readonly header?: ChatItemHeader + readonly fullWidth?: boolean + readonly padding?: boolean + readonly codeBlockActions?: CodeBlockActions | null + readonly rootFolderTitle?: string } export class ChatMessage extends UiMessage { @@ -342,6 +368,15 @@ export class ChatMessage extends UiMessage { readonly userIntent: string | undefined readonly codeBlockLanguage: string | undefined readonly contextList: DocumentReference[] | undefined + readonly title?: string + readonly buttons?: ChatItemButton[] + readonly fileList?: ChatItemContent['fileList'] + readonly header?: ChatItemHeader + readonly fullWidth?: boolean + readonly padding?: boolean + readonly codeBlockActions?: CodeBlockActions | null + readonly canBeVoted?: boolean = false + readonly rootFolderTitle?: string override type = 'chatMessage' constructor(props: ChatMessageProps, tabID: string) { @@ -357,9 +392,22 @@ export class ChatMessage extends UiMessage { this.userIntent = props.userIntent this.codeBlockLanguage = props.codeBlockLanguage this.contextList = props.contextList + this.title = props.title + this.buttons = props.buttons + this.fileList = props.fileList + this.canBeVoted = props.canBeVoted + this.header = props.header + this.fullWidth = props.fullWidth + this.padding = props.padding + this.codeBlockActions = props.codeBlockActions + this.rootFolderTitle = props.rootFolderTitle } } +export class ToolMessage extends ChatMessage { + override type = 'toolMessage' +} + export interface FollowUp { readonly type: string readonly pillText: string @@ -414,6 +462,10 @@ export class AppToWebViewMessageDispatcher { this.appsToWebViewMessagePublisher.publish(message) } + public sendToolMessage(message: ToolMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + public sendEditorContextCommandMessage(message: EditorContextCommandMessage) { this.appsToWebViewMessagePublisher.publish(message) } @@ -461,4 +513,12 @@ export class AppToWebViewMessageDispatcher { public sendShowCustomFormMessage(message: ShowCustomFormMessage) { this.appsToWebViewMessagePublisher.publish(message) } + + public sendCustomFormActionMessage(message: CustomFormActionMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } + + public sendAsyncEventProgress(message: AsyncEventProgressMessage) { + this.appsToWebViewMessagePublisher.publish(message) + } } diff --git a/packages/core/src/codewhispererChat/view/messages/messageListener.ts b/packages/core/src/codewhispererChat/view/messages/messageListener.ts index 8ad59acb0a7..4faa216dd61 100644 --- a/packages/core/src/codewhispererChat/view/messages/messageListener.ts +++ b/packages/core/src/codewhispererChat/view/messages/messageListener.ts @@ -90,6 +90,9 @@ export class UIMessageListener { getLogger().error('chatItemFeedback failed: %s', (e as Error).message) }) break + case 'prompt-input-option-change': + this.promptInputOptionChange(msg) + break case 'ui-focus': this.processUIFocus(msg) break @@ -243,9 +246,8 @@ export class UIMessageListener { }) } - private processInsertCodeAtCursorPosition(msg: any) { - this.referenceLogController.addReferenceLog(msg.codeReference, (msg.code as string) ?? '') - this.chatControllerMessagePublishers.processInsertCodeAtCursorPosition.publish({ + private createCommonMessagePayload(msg: any) { + return { command: msg.command, tabID: msg.tabID, messageId: msg.messageId, @@ -257,7 +259,16 @@ export class UIMessageListener { codeBlockIndex: msg.codeBlockIndex, totalCodeBlocks: msg.totalCodeBlocks, codeBlockLanguage: msg.codeBlockLanguage, - }) + } + } + private processInsertCodeAtCursorPosition(msg: any) { + this.referenceLogController.addReferenceLog(msg.codeReference, (msg.code as string) ?? '') + this.chatControllerMessagePublishers.processInsertCodeAtCursorPosition.publish( + this.createCommonMessagePayload(msg) + ) + } + private processCodeWasCopiedToClipboard(msg: any) { + this.chatControllerMessagePublishers.processCopyCodeToClipboard.publish(this.createCommonMessagePayload(msg)) } private processAcceptDiff(msg: any) { @@ -276,22 +287,6 @@ export class UIMessageListener { }) } - private processCodeWasCopiedToClipboard(msg: any) { - this.chatControllerMessagePublishers.processCopyCodeToClipboard.publish({ - command: msg.command, - tabID: msg.tabID, - messageId: msg.messageId, - userIntent: msg.userIntent, - code: msg.code, - insertionTargetType: msg.insertionTargetType, - codeReference: msg.codeReference, - eventId: msg.eventId, - codeBlockIndex: msg.codeBlockIndex, - totalCodeBlocks: msg.totalCodeBlocks, - codeBlockLanguage: msg.codeBlockLanguage, - }) - } - private processTabWasRemoved(msg: any) { this.chatControllerMessagePublishers.processTabClosedMessage.publish({ tabID: msg.tabID, @@ -356,4 +351,13 @@ export class UIMessageListener { filePath: msg.filePath, }) } + + private promptInputOptionChange(msg: any) { + this.chatControllerMessagePublishers.processPromptInputOptionChange.publish({ + messageId: msg.messageId, + tabID: msg.tabID, + command: msg.command, + optionsValues: msg.optionsValues, + }) + } } diff --git a/packages/core/src/shared/clients/codewhispererChatClient.ts b/packages/core/src/shared/clients/codewhispererChatClient.ts index 93b7b3bceb4..2dc0e73483c 100644 --- a/packages/core/src/shared/clients/codewhispererChatClient.ts +++ b/packages/core/src/shared/clients/codewhispererChatClient.ts @@ -7,6 +7,7 @@ import { ConfiguredRetryStrategy } from '@smithy/util-retry' import { getCodewhispererConfig } from '../../codewhisperer/client/codewhisperer' import { AuthUtil } from '../../codewhisperer/util/authUtil' import { getUserAgent } from '../telemetry/util' +import { defaultStreamingResponseTimeoutInMs } from '../../codewhispererChat/constants' // Create a client for featureDev streaming based off of aws sdk v3 export async function createCodeWhispererChatStreamingClient(): Promise { @@ -17,6 +18,10 @@ export async function createCodeWhispererChatStreamingClient(): Promise 500 + attempt ** 10), }) return streamingClient diff --git a/packages/core/src/shared/db/chatDb/chatDb.ts b/packages/core/src/shared/db/chatDb/chatDb.ts index df936601799..2b949b23869 100644 --- a/packages/core/src/shared/db/chatDb/chatDb.ts +++ b/packages/core/src/shared/db/chatDb/chatDb.ts @@ -19,6 +19,11 @@ import { import crypto from 'crypto' import path from 'path' import { fs } from '../../fs/fs' +import { getLogger } from '../../logger/logger' +import { ChatMessage, ToolResultStatus } from '@amzn/codewhisperer-streaming' + +// Maximum number of characters to keep in history +const MaxConversationHistoryCharacters = 600_000 /** * A singleton database class that manages chat history persistence using LokiJS. @@ -36,6 +41,7 @@ import { fs } from '../../fs/fs' export class Database { private static instance: Database | undefined = undefined private db: Loki + private logger = getLogger('chatHistoryDb') /** * Keep track of which open tabs have a corresponding history entry. Maps tabIds to historyIds */ @@ -48,6 +54,8 @@ export class Database { const workspaceId = this.getWorkspaceIdentifier() const dbName = `chat-history-${workspaceId}.json` + this.logger.debug(`Initializing database at ${this.dbDirectory}/${dbName}`) + this.db = new Loki(dbName, { adapter: new FileSystemAdapter(this.dbDirectory), autosave: true, @@ -66,6 +74,7 @@ export class Database { } setHistoryIdMapping(tabId: string, historyId: string) { + this.logger.debug(`[Setting historyIdMapping: tabId=${tabId}, historyId=${historyId}`) this.historyIdMapping.set(tabId, historyId) } @@ -92,12 +101,14 @@ export class Database { } // Case 4: No workspace + this.logger.debug(`No workspace found, using default identifier: 'no-workspace'`) return 'no-workspace' } async databaseInitialize() { let entries = this.db.getCollection(TabCollection) if (entries === null) { + this.logger.info(`Creating new tabs collection`) entries = this.db.addCollection(TabCollection, { unique: ['historyId'], indices: ['updatedAt', 'isOpen'], @@ -133,14 +144,57 @@ export class Database { return undefined } + getActiveConversation(historyId: string): Conversation | undefined { + const tabCollection = this.db.getCollection(TabCollection) + const tabData = tabCollection.findOne({ historyId }) + + if (!tabData?.conversations.length) { + this.logger.debug('No active conversations found') + return undefined + } + + return tabData.conversations[0] + } + clearTab(tabId: string) { if (this.initialized) { const tabCollection = this.db.getCollection(TabCollection) const historyId = this.historyIdMapping.get(tabId) + this.logger.info(`Clearing tab: tabId=${tabId}, historyId=${historyId || 'undefined'}`) if (historyId) { tabCollection.findAndRemove({ historyId }) + this.logger.debug(`Removed tab with historyId=${historyId} from collection`) } this.historyIdMapping.delete(tabId) + this.logger.debug(`Removed tabId=${tabId} from historyIdMapping`) + } + } + + // Removes the most recent message(s) from the chat history for a given tab + clearRecentHistory(tabId: string): void { + if (this.initialized) { + const historyId = this.historyIdMapping.get(tabId) + this.logger.info(`Clearing recent history: tabId=${tabId}, historyId=${historyId || 'undefined'}`) + if (historyId) { + const tabCollection = this.db.getCollection(TabCollection) + const tabData = tabCollection.findOne({ historyId }) + if (tabData) { + const activeConversation = tabData.conversations[0] + const allMessages = this.getMessages(tabId) + const lastMessage = allMessages[allMessages.length - 1] + this.logger.debug(`Last message type: ${lastMessage.type}`) + + if (lastMessage.type === ('prompt' as ChatItemType)) { + allMessages.pop() + this.logger.info(`Removed last user message`) + } else { + allMessages.splice(-2) + this.logger.info(`Removed last assistant message and user message`) + } + activeConversation.messages = allMessages + tabCollection.update(tabData) + } + } } } @@ -148,13 +202,18 @@ export class Database { if (this.initialized) { const tabCollection = this.db.getCollection(TabCollection) const historyId = this.historyIdMapping.get(tabId) + this.logger.info( + `Updating tab open state: tabId=${tabId}, historyId=${historyId || 'undefined'}, isOpen=${isOpen}` + ) if (historyId) { tabCollection.findAndUpdate({ historyId }, (tab: Tab) => { tab.isOpen = isOpen return tab }) + this.logger.debug(`Updated tab open state in collection`) if (!isOpen) { this.historyIdMapping.delete(tabId) + this.logger.debug(`Removed tabId=${tabId} from historyIdMapping`) } } } @@ -164,9 +223,11 @@ export class Database { let searchResults: DetailedListItemGroup[] = [] if (this.initialized) { if (!filter) { + this.logger.info(`Empty search filter, returning all history`) return this.getHistory() } + this.logger.info(`Searching messages with filter: "${filter}"`) const searchTermLower = filter.toLowerCase() const tabCollection = this.db.getCollection(TabCollection) const tabs = tabCollection.find() @@ -177,9 +238,11 @@ export class Database { }) }) }) + this.logger.info(`Found ${filteredTabs.length} matching tabs`) searchResults = groupTabsByDate(filteredTabs) } if (searchResults.length === 0) { + this.logger.info(`No search results found, returning default message`) searchResults = [{ children: [{ description: 'No matches found' }] }] } return searchResults @@ -190,10 +253,13 @@ export class Database { * @param tabId The ID of the tab to get messages from * @param numMessages Optional number of most recent messages to return. If not provided, returns all messages. */ - getMessages(tabId: string, numMessages?: number) { + getMessages(tabId: string, numMessages?: number): Message[] { if (this.initialized) { const tabCollection = this.db.getCollection(TabCollection) const historyId = this.historyIdMapping.get(tabId) + this.logger.info( + `Getting messages: tabId=${tabId}, historyId=${historyId || 'undefined'}, numMessages=${numMessages || 'all'}` + ) const tabData = historyId ? tabCollection.findOne({ historyId }) : undefined if (tabData) { const allMessages = tabData.conversations.flatMap((conversation: Conversation) => conversation.messages) @@ -210,6 +276,7 @@ export class Database { if (this.initialized) { const tabCollection = this.db.getCollection(TabCollection) const tabs = tabCollection.find() + this.logger.debug(`Getting history from ${tabs.length} tabs`) return groupTabsByDate(tabs) } return [] @@ -218,6 +285,7 @@ export class Database { deleteHistory(historyId: string) { if (this.initialized) { const tabCollection = this.db.getCollection(TabCollection) + this.logger.info(`Deleting history: historyId=${historyId}`) tabCollection.findAndRemove({ historyId }) const tabId = this.getOpenTabId(historyId) if (tabId) { @@ -229,23 +297,30 @@ export class Database { addMessage(tabId: string, tabType: TabType, conversationId: string, message: Message) { if (this.initialized) { const tabCollection = this.db.getCollection(TabCollection) + this.logger.info(`Adding message: tabId=${tabId}, tabType=${tabType}, conversationId=${conversationId}`) let historyId = this.historyIdMapping.get(tabId) if (!historyId) { historyId = crypto.randomUUID() + this.logger.debug(`No historyId found, creating new one: ${historyId}`) this.setHistoryIdMapping(tabId, historyId) } const tabData = historyId ? tabCollection.findOne({ historyId }) : undefined const tabTitle = - (message.type === ('prompt' as ChatItemType) ? message.body : tabData?.title) || 'Amazon Q Chat' + message.type === ('prompt' as ChatItemType) && message.body.trim().length > 0 + ? message.body + : tabData?.title || 'Amazon Q Chat' + message = this.formatChatHistoryMessage(message) if (tabData) { + this.logger.info(`Found existing tab data, updating conversations`) tabData.conversations = updateOrCreateConversation(tabData.conversations, conversationId, message) tabData.updatedAt = new Date() tabData.title = tabTitle tabCollection.update(tabData) } else { + this.logger.info(`No existing tab data, creating new tab entry`) tabCollection.insert({ historyId, updatedAt: new Date(), @@ -257,4 +332,196 @@ export class Database { } } } + + private formatChatHistoryMessage(message: Message): Message { + if (message.type === ('prompt' as ChatItemType)) { + return { + ...message, + userInputMessageContext: { + // Only keep toolResults in history + toolResults: message.userInputMessageContext?.toolResults, + }, + } + } + return message + } + + /** + * Fixes the history to maintain the following invariants: + * 1. The history character length is <= MAX_CONVERSATION_HISTORY_CHARACTERS. Oldest messages are dropped. + * 2. The first message is from the user. Oldest messages are dropped if needed. + * 3. The last message is from the assistant. The last message is dropped if it is from the user. + * 4. If the last message is from the assistant and it contains tool uses, and a next user + * message is set without tool results, then the user message will have cancelled tool results. + */ + fixHistory(tabId: string, newUserMessage: ChatMessage): void { + if (!this.initialized) { + return + } + const historyId = this.historyIdMapping.get(tabId) + this.logger.info(`Fixing history: tabId=${tabId}, historyId=${historyId || 'undefined'}`) + + if (!historyId) { + return + } + + const tabCollection = this.db.getCollection(TabCollection) + const tabData = tabCollection.findOne({ historyId }) + if (!tabData) { + return + } + + const activeConversation = tabData.conversations[0] + let allMessages = activeConversation.messages + this.logger.info(`Found ${allMessages.length} messages in conversation`) + + // Drop empty assistant partial if it’s the last message + this.handleEmptyAssistantMessage(allMessages) + + // Make sure max characters ≤ MaxConversationHistoryCharacters + allMessages = this.trimMessagesToMaxLength(allMessages) + + // Ensure messages in history a valid for server side checks + this.ensureValidMessageSequence(allMessages) + + // If the last message is from the assistant and it contains tool uses, and a next user message is set without tool results, then the user message will have cancelled tool results. + this.handleToolUses(allMessages, newUserMessage) + + activeConversation.messages = allMessages + tabCollection.update(tabData) + this.logger.info(`Updated tab data in collection`) + } + + private handleEmptyAssistantMessage(messages: Message[]): void { + if (messages.length === 0) { + return + } + + const lastMsg = messages[messages.length - 1] + if ( + lastMsg.type === ('answer' as ChatItemType) && + (!lastMsg.body || lastMsg.body.trim().length === 0) && + (!lastMsg.toolUses || lastMsg.toolUses.length === 0) + ) { + this.logger.debug( + 'Last message is empty partial assistant. Removed last assistant message and user message' + ) + messages.splice(-2) + } + } + + private trimMessagesToMaxLength(messages: Message[]): Message[] { + let totalCharacters = this.calculateCharacterCount(messages) + while (totalCharacters > MaxConversationHistoryCharacters && messages.length > 2) { + // Find the next valid user message to start from + const indexToTrim = this.findIndexToTrim(messages) + if (indexToTrim !== undefined && indexToTrim > 0) { + this.logger.debug( + `Removing the first ${indexToTrim} elements in the history due to character count limit` + ) + messages.splice(0, indexToTrim) + } else { + this.logger.debug('Could not find a valid point to trim, reset history to reduce character count') + return [] + } + totalCharacters = this.calculateCharacterCount(messages) + } + return messages + } + + private calculateCharacterCount(allMessages: Message[]): number { + let count = 0 + for (const message of allMessages) { + // Count characters of all message text + count += message.body.length + + // Count characters in tool uses + if (message.toolUses) { + try { + for (const toolUse of message.toolUses) { + count += JSON.stringify(toolUse).length + } + } catch (e) { + this.logger.error(`Error counting toolUses: ${String(e)}`) + } + } + // Count characters in tool results + if (message.userInputMessageContext?.toolResults) { + try { + for (const toolResul of message.userInputMessageContext.toolResults) { + count += JSON.stringify(toolResul).length + } + } catch (e) { + this.logger.error(`Error counting toolResults: ${String(e)}`) + } + } + } + this.logger.debug(`Current history characters: ${count}`) + return count + } + + private findIndexToTrim(allMessages: Message[]): number | undefined { + for (let i = 2; i < allMessages.length; i++) { + const message = allMessages[i] + if (message.type === ('prompt' as ChatItemType) && this.isValidUserMessageWithoutToolResults(message)) { + return i + } + } + return undefined + } + + private isValidUserMessageWithoutToolResults(message: Message): boolean { + const ctx = message.userInputMessageContext + return !!ctx && (!ctx.toolResults || ctx.toolResults.length === 0) && message.body !== '' + } + + private ensureValidMessageSequence(messages: Message[]): void { + // Make sure the first stored message is from the user (type === 'prompt'), else drop + while (messages.length > 0 && messages[0].type === ('answer' as ChatItemType)) { + messages.shift() + this.logger.debug('Dropped first message since it is not from user') + } + + // Make sure the last stored message is from the assistant (type === 'answer'), else drop + if (messages.length > 0 && messages[messages.length - 1].type === ('prompt' as ChatItemType)) { + messages.pop() + this.logger.debug('Dropped trailing user message') + } + } + + private handleToolUses(messages: Message[], newUserMessage: ChatMessage): void { + if (messages.length === 0) { + if (newUserMessage.userInputMessage?.userInputMessageContext?.toolResults) { + this.logger.debug('No history message found, but new user message has tool results.') + newUserMessage.userInputMessage.userInputMessageContext.toolResults = undefined + // tool results are empty, so content must not be empty + newUserMessage.userInputMessage.content = 'Conversation history was too large, so it was cleared.' + } + return + } + + const lastMsg = messages[messages.length - 1] + if (lastMsg.toolUses && lastMsg.toolUses.length > 0) { + const toolResults = newUserMessage.userInputMessage?.userInputMessageContext?.toolResults + if (!toolResults || toolResults.length === 0) { + this.logger.debug( + `No tools results in last user message following a tool use message from assisstant, marking as canceled` + ) + if (newUserMessage.userInputMessage?.userInputMessageContext) { + newUserMessage.userInputMessage.userInputMessageContext.toolResults = lastMsg.toolUses.map( + (toolUse) => ({ + toolUseId: toolUse.toolUseId, + content: [ + { + type: 'Text', + text: 'Tool use was cancelled by the user', + }, + ], + status: ToolResultStatus.ERROR, + }) + ) + } + } + } + } } diff --git a/packages/core/src/shared/localizedText.ts b/packages/core/src/shared/localizedText.ts index 9bd727fb1bd..c2acf092915 100644 --- a/packages/core/src/shared/localizedText.ts +++ b/packages/core/src/shared/localizedText.ts @@ -18,6 +18,8 @@ export const invalidArn = localize('AWS.error.invalidArn', 'Invalid ARN') export const localizedDelete = localize('AWS.generic.delete', 'Delete') export const cancel = localize('AWS.generic.cancel', 'Cancel') export const help = localize('AWS.generic.help', 'Help') +export const run = localize('AWS.generic.run', 'Run') +export const reject = localize('AWS.generic.reject', 'Reject') export const invalidNumberWarning = localize('AWS.validateTime.error.invalidNumber', 'Input must be a positive number') export const viewDocs = localize('AWS.generic.viewDocs', 'View Documentation') export const recentlyUsed = localize('AWS.generic.recentlyUsed', 'recently used') diff --git a/packages/core/src/shared/logger/logger.ts b/packages/core/src/shared/logger/logger.ts index 3338602685a..336e0a80978 100644 --- a/packages/core/src/shared/logger/logger.ts +++ b/packages/core/src/shared/logger/logger.ts @@ -16,6 +16,13 @@ export type LogTopic = | 'amazonqLsp' | 'chat' | 'stepfunctions' + | 'fsRead' + | 'fsWrite' + | 'executeBash' + | 'grepSearch' + | 'listDirectory' + | 'chatStream' + | 'chatHistoryDb' | 'unknown' class ErrorLog { diff --git a/packages/core/src/shared/utilities/diffUtils.ts b/packages/core/src/shared/utilities/diffUtils.ts index f6dcd9d3fe4..65957bbe1f2 100644 --- a/packages/core/src/shared/utilities/diffUtils.ts +++ b/packages/core/src/shared/utilities/diffUtils.ts @@ -149,3 +149,56 @@ export function getDiffCharsAndLines( addedLines, } } + +/** + * Calculates the number of added and deleted lines from a set of changes. + * + * @param {Change[] | undefined} changes - An array of diff Change objects containing added, removed, or unchanged content + * @returns {Object} An object containing the number of added and deleted lines + * @example + * // Calculate the number of added and deleted lines from diff changes + * const diffChanges = diffLines(originalCode, newCode); + * const lineDiffs = getDiffLinesFromChanges(diffChanges); + * // Result will be an object with added and deleted line counts + */ +export function getDiffLinesFromChanges(changes: Change[] | undefined) { + return changes?.reduce( + (acc, { count = 0, added, removed }) => { + if (added) { + acc.added += count + } else if (removed) { + acc.deleted += count + } + return acc + }, + { added: 0, deleted: 0 } + ) +} + +/** + * Converts diff changes into a markdown-formatted string with syntax highlighting. + * This function takes an array of diff changes and formats them as a markdown code block + * with diff syntax, optionally including language-specific syntax highlighting. + * + * @param {Change[]} changes - An array of diff Change objects containing added, removed, or unchanged content + * @param {string} [language] - Optional language identifier for syntax highlighting in the markdown output + * @returns {string} A markdown-formatted string representing the diff with proper syntax highlighting + * @example + * // Generate diff markdown for JavaScript changes + * const diffChanges = diffLines(originalCode, newCode); + * const markdown = getDiffMarkdown(diffChanges, 'javascript'); + * // Result will be a markdown code block with diff syntax and JavaScript highlighting + */ +export function getDiffMarkdown(changes: Change[], language?: string): string { + return ['```diff' + (language ? `-${language}` : ''), ...changes.flatMap(formatDiffPart), '```'].join('\n') +} + +function formatDiffPart(part: Change) { + const prefix = part.added ? '+' : part.removed ? '-' : ' ' + const lines = part.value.split('\n') + + if (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop() + } + return lines.map((line) => `${prefix}${line}`) +} diff --git a/packages/core/src/shared/utilities/messageUtil.ts b/packages/core/src/shared/utilities/messageUtil.ts new file mode 100644 index 00000000000..6cd811c4516 --- /dev/null +++ b/packages/core/src/shared/utilities/messageUtil.ts @@ -0,0 +1,31 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CodeWhispererStreamingServiceException } from '@amzn/codewhisperer-streaming' +import { getHttpStatusCode, getRequestId } from '../errors' + +export interface MessageErrorInfo { + errorMessage: string + statusCode?: number + requestId?: string +} + +export function extractErrorInfo(error: any): MessageErrorInfo { + let errorMessage = 'Error reading chat response stream: ' + error.message + let statusCode = undefined + let requestId = undefined + + if (error instanceof CodeWhispererStreamingServiceException) { + errorMessage = error.message + statusCode = getHttpStatusCode(error) ?? 0 + requestId = getRequestId(error) + } + + return { + errorMessage, + statusCode, + requestId, + } +} diff --git a/packages/core/src/shared/utilities/textDocumentUtilities.ts b/packages/core/src/shared/utilities/textDocumentUtilities.ts index 48a20a6c44b..da5b19ef42e 100644 --- a/packages/core/src/shared/utilities/textDocumentUtilities.ts +++ b/packages/core/src/shared/utilities/textDocumentUtilities.ts @@ -224,6 +224,27 @@ export async function addEofNewline(editor: vscode.TextEditor) { } } +/** + * Determines the language identifier for a file at the specified path. + * This function attempts to open the file as a VS Code document to retrieve its language ID. + * + * @param {string} filePath - The absolute or relative path to the file whose language should be determined. + * @returns {Promise} A promise that resolves to the language identifier of the file, + * or undefined if the file cannot be opened or the language cannot be determined. + * @example + * // Get language for a JavaScript file + * const language = await getLanguageForFilePath('/path/to/file.js'); + * // language will be 'javascript' + */ +export async function getLanguageForFilePath(filePath: string): Promise { + try { + const document = await vscode.workspace.openTextDocument(filePath) + return document.languageId + } catch (err) { + return + } +} + class ReadonlyTextDocumentProvider implements vscode.TextDocumentContentProvider { private content = '' diff --git a/packages/core/src/shared/utilities/workspaceUtils.ts b/packages/core/src/shared/utilities/workspaceUtils.ts index 12cce75b3ff..06d725582e3 100644 --- a/packages/core/src/shared/utilities/workspaceUtils.ts +++ b/packages/core/src/shared/utilities/workspaceUtils.ts @@ -671,3 +671,81 @@ export async function findStringInDirectory(searchStr: string, dirPath: string) }) return spawnResult } + +/** + * Returns a prefix for a directory ('[DIR]'), symlink ('[LINK]'), or file ('[FILE]'). + */ +export function formatListing(name: string, fileType: vscode.FileType, fullPath: string): string { + let typeChar = '[FILE]' + if (fileType === vscode.FileType.Directory) { + typeChar = '[DIR]' + } else if (fileType === vscode.FileType.SymbolicLink) { + typeChar = '[LINK]' + } + return `${typeChar} ${fullPath}` +} + +/** + * Recursively lists directories using a BFS approach, returning lines like: + * d /absolute/path/to/folder + * - /absolute/path/to/file.txt + * + * You can either pass a custom callback or rely on the default `formatListing`. + * + * @param dirUri The folder to begin traversing + * @param maxDepth Maximum depth to descend (0 => just this folder, if it's missing => recursively) + * @param customFormatCallback Optional. If given, it will override the default line-formatting + */ +export async function readDirectoryRecursively( + dirUri: vscode.Uri, + maxDepth?: number, + customFormatCallback?: (name: string, fileType: vscode.FileType, fullPath: string) => string +): Promise { + const logger = getLogger() + const depthDescription = maxDepth === undefined ? 'unlimited' : maxDepth + logger.info(`Reading directory: ${dirUri.fsPath} to max depth: ${depthDescription}`) + + const queue: Array<{ uri: vscode.Uri; depth: number }> = [{ uri: dirUri, depth: 0 }] + const results: string[] = [] + + const formatter = customFormatCallback ?? formatListing + + while (queue.length > 0) { + const { uri, depth } = queue.shift()! + if (maxDepth !== undefined && depth > maxDepth) { + logger.info(`Skipping directory: ${uri.fsPath} (depth ${depth} > max ${maxDepth})`) + continue + } + + let entries: [string, vscode.FileType][] + try { + entries = await fs.readdir(uri) + } catch (err) { + logger.error(`Cannot read directory: ${uri.fsPath} (${err})`) + results.push(`Cannot read directory: ${uri.fsPath} (${err})`) + continue + } + + for (const [name, fileType] of entries) { + const childUri = vscode.Uri.joinPath(uri, name) + results.push(formatter(name, fileType, childUri.fsPath)) + + if (fileType === vscode.FileType.Directory && (maxDepth === undefined || depth < maxDepth)) { + queue.push({ uri: childUri, depth: depth + 1 }) + } + } + } + + return results +} + +export function getWorkspacePaths() { + const workspaceFolders = vscode.workspace.workspaceFolders + return workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [] +} + +export function getWorkspaceForFile(filepath: string) { + const fileUri = vscode.Uri.file(filepath) + const workspaceFolder = vscode.workspace.getWorkspaceFolder(fileUri) + return workspaceFolder?.uri.fsPath +} diff --git a/packages/core/src/test/codewhispererChat/tools/executeBash.test.ts b/packages/core/src/test/codewhispererChat/tools/executeBash.test.ts new file mode 100644 index 00000000000..54dfb3f02a0 --- /dev/null +++ b/packages/core/src/test/codewhispererChat/tools/executeBash.test.ts @@ -0,0 +1,169 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { strict as assert } from 'assert' +import sinon from 'sinon' +import { destructiveCommandWarningMessage, ExecuteBash } from '../../../codewhispererChat/tools/executeBash' +import { ChildProcess } from '../../../shared/utilities/processUtils' +import * as vscode from 'vscode' + +describe('ExecuteBash Tool', () => { + let runStub: sinon.SinonStub + let invokeStub: sinon.SinonStub + + beforeEach(() => { + runStub = sinon.stub(ChildProcess.prototype, 'run') + invokeStub = sinon.stub(ExecuteBash.prototype, 'invoke') + }) + + afterEach(() => { + sinon.restore() + }) + + it('pass validation for a safe command (read-only)', async () => { + runStub.resolves({ + exitCode: 0, + stdout: '/bin/ls', + stderr: '', + error: undefined, + signal: undefined, + }) + const execBash = new ExecuteBash({ command: 'ls' }) + await execBash.validate() + }) + + it('fail validation if the command is empty', async () => { + const execBash = new ExecuteBash({ command: ' ' }) + await assert.rejects( + execBash.validate(), + /Bash command cannot be empty/i, + 'Expected an error for empty command' + ) + }) + + it('set requiresAcceptance=true if the command has dangerous patterns', () => { + const execBash = new ExecuteBash({ command: 'ls && rm -rf /' }) + const needsAcceptance = execBash.requiresAcceptance().requiresAcceptance + assert.equal(needsAcceptance, true, 'Should require acceptance for dangerous pattern') + assert.equal( + execBash.requiresAcceptance().warning, + destructiveCommandWarningMessage, + 'Warning message should match the destructiveCommandWarningMessage' + ) + }) + + it('set requiresAcceptance=false if it is a read-only command', () => { + const execBash = new ExecuteBash({ command: 'cat file.txt' }) + const needsAcceptance = execBash.requiresAcceptance().requiresAcceptance + assert.equal(needsAcceptance, false, 'Read-only command should not require acceptance') + }) + + it('whichCommand cannot find the first arg', async () => { + runStub.resolves({ + exitCode: 1, + stdout: '', + stderr: '', + error: undefined, + signal: undefined, + }) + + const execBash = new ExecuteBash({ command: 'noSuchCmd' }) + await assert.rejects(execBash.validate(), /not found on PATH/i, 'Expected not found error from whichCommand') + }) + + it('whichCommand sees first arg on PATH', async () => { + runStub.resolves({ + exitCode: 0, + stdout: '/usr/bin/noSuchCmd\n', + stderr: '', + error: undefined, + signal: undefined, + }) + + const execBash = new ExecuteBash({ command: 'noSuchCmd' }) + await execBash.validate() + }) + + it('stub invoke() call', async () => { + invokeStub.resolves({ + output: { + kind: 'json', + content: { + exitStatus: '0', + stdout: 'mocked stdout lines', + stderr: '', + }, + }, + }) + + const execBash = new ExecuteBash({ command: 'ls' }) + + const dummyWritable = { write: () => {} } as any + const result = await execBash.invoke(dummyWritable) + + assert.strictEqual(result.output.kind, 'json') + const out = result.output.content as unknown as { + exitStatus: string + stdout: string + stderr: string + } + assert.strictEqual(out.exitStatus, '0') + assert.strictEqual(out.stdout, 'mocked stdout lines') + assert.strictEqual(out.stderr, '') + + assert.strictEqual(invokeStub.callCount, 1) + }) + + it('requires acceptance if the command references an absolute file path outside the workspace', () => { + // Stub workspace folders to simulate a workspace at '/workspace/folder' + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + // Command references an absolute path outside the workspace + const execBash = new ExecuteBash({ command: 'cat /not/in/workspace/file.txt', cwd: '/workspace/folder' }) + const result = execBash.requiresAcceptance() + + assert.equal( + result.requiresAcceptance, + true, + 'Should require acceptance for an absolute path outside of workspace' + ) + workspaceStub.restore() + }) + + it('does NOT require acceptance if the command references a relative file path inside the workspace', () => { + // Stub workspace folders to simulate a workspace at '/workspace/folder' + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + + // Command references a relative path that resolves within the workspace + const execBash = new ExecuteBash({ command: 'cat ./file.txt', cwd: '/workspace/folder' }) + const result = execBash.requiresAcceptance() + + assert.equal(result.requiresAcceptance, false, 'Relative path inside workspace should not require acceptance') + + workspaceStub.restore() + }) + + it('does NOT require acceptance if there is no path-like token in the command', () => { + // Stub workspace folders (even though they are not used in this case) + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + + // Command with tokens that do not look like file paths + const execBash = new ExecuteBash({ command: 'echo hello world', cwd: '/workspace/folder' }) + const result = execBash.requiresAcceptance() + + assert.equal( + result.requiresAcceptance, + false, + 'A command without any path-like token should not require acceptance' + ) + + workspaceStub.restore() + }) +}) diff --git a/packages/core/src/test/codewhispererChat/tools/fsRead.test.ts b/packages/core/src/test/codewhispererChat/tools/fsRead.test.ts new file mode 100644 index 00000000000..568c2314ee0 --- /dev/null +++ b/packages/core/src/test/codewhispererChat/tools/fsRead.test.ts @@ -0,0 +1,100 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'assert' +import { FsRead } from '../../../codewhispererChat/tools/fsRead' +import { TestFolder } from '../../testUtil' +import path from 'path' +import * as vscode from 'vscode' +import sinon from 'sinon' + +describe('FsRead Tool', () => { + let testFolder: TestFolder + + before(async () => { + testFolder = await TestFolder.create() + }) + + afterEach(() => { + sinon.restore() + }) + + it('throws if path is empty', async () => { + const fsRead = new FsRead({ path: '' }) + await assert.rejects(fsRead.validate(), /Path cannot be empty/i, 'Expected an error about empty path') + }) + + it('reads entire file', async () => { + const fileContent = 'Line 1\nLine 2\nLine 3' + const filePath = await testFolder.write('fullFile.txt', fileContent) + + const fsRead = new FsRead({ path: filePath }) + await fsRead.validate() + const result = await fsRead.invoke(process.stdout) + + assert.strictEqual(result.output.kind, 'text', 'Output kind should be "text"') + assert.strictEqual(result.output.content, fileContent, 'File content should match exactly') + }) + + it('reads partial lines of a file', async () => { + const fileContent = 'A\nB\nC\nD\nE\nF' + const filePath = await testFolder.write('partialFile.txt', fileContent) + + const fsRead = new FsRead({ path: filePath, readRange: [2, 4] }) + await fsRead.validate() + const result = await fsRead.invoke(process.stdout) + + assert.strictEqual(result.output.kind, 'text') + assert.strictEqual(result.output.content, 'B\nC\nD') + }) + + it('throws error if path does not exist', async () => { + const missingPath = path.join(testFolder.path, 'no_such_file.txt') + const fsRead = new FsRead({ path: missingPath }) + + await assert.rejects( + fsRead.validate(), + /does not exist or cannot be accessed/i, + 'Expected an error indicating the path does not exist' + ) + }) + + it('invalid line range', async () => { + const filePath = await testFolder.write('rangeTest.txt', '1\n2\n3') + const fsRead = new FsRead({ path: filePath, readRange: [3, 2] }) + + await fsRead.validate() + const result = await fsRead.invoke(process.stdout) + assert.strictEqual(result.output.kind, 'text') + assert.strictEqual(result.output.content, '') + }) + + it('should require acceptance if fsPath is outside the workspace', () => { + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + const fsRead = new FsRead({ path: '/not/in/workspace/file.txt' }) + const result = fsRead.requiresAcceptance() + assert.equal( + result.requiresAcceptance, + true, + 'Expected requiresAcceptance to be true for a path outside the workspace' + ) + workspaceStub.restore() + }) + + it('should not require acceptance if fsPath is inside the workspace', () => { + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + const fsRead = new FsRead({ path: '/workspace/folder/file.txt' }) + const result = fsRead.requiresAcceptance() + assert.equal( + result.requiresAcceptance, + false, + 'Expected requiresAcceptance to be false for a path inside the workspace' + ) + workspaceStub.restore() + }) +}) diff --git a/packages/core/src/test/codewhispererChat/tools/fsWrite.test.ts b/packages/core/src/test/codewhispererChat/tools/fsWrite.test.ts new file mode 100644 index 00000000000..932575c97d6 --- /dev/null +++ b/packages/core/src/test/codewhispererChat/tools/fsWrite.test.ts @@ -0,0 +1,417 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { + AppendParams, + CreateParams, + FsWrite, + InsertParams, + StrReplaceParams, +} from '../../../codewhispererChat/tools/fsWrite' +import { TestFolder } from '../../testUtil' +import path from 'path' +import assert from 'assert' +import { fs } from '../../../shared/fs/fs' +import { InvokeOutput, OutputKind } from '../../../codewhispererChat/tools/toolShared' + +describe('FsWrite Tool', function () { + let testFolder: TestFolder + const expectedOutput: InvokeOutput = { + output: { + kind: OutputKind.Text, + content: '', + }, + } + + describe('handleCreate', function () { + before(async function () { + testFolder = await TestFolder.create() + }) + + it('creates a new file with fileText content', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + const fileExists = await fs.existsFile(filePath) + assert.ok(!fileExists) + + const params: CreateParams = { + command: 'create', + fileText: 'Hello World', + path: filePath, + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const content = await fs.readFileText(filePath) + assert.strictEqual(content, 'Hello World') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('replaces existing file with fileText content', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + const fileExists = await fs.existsFile(filePath) + assert.ok(fileExists) + + const params: CreateParams = { + command: 'create', + fileText: 'Goodbye', + path: filePath, + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const content = await fs.readFileText(filePath) + assert.strictEqual(content, 'Goodbye') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('uses newStr when fileText is not provided', async function () { + const filePath = path.join(testFolder.path, 'file2.txt') + + const params: CreateParams = { + command: 'create', + newStr: 'Hello World', + path: filePath, + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const content = await fs.readFileText(filePath) + assert.strictEqual(content, 'Hello World') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('creates an empty file when no content is provided', async function () { + const filePath = path.join(testFolder.path, 'file3.txt') + + const params: CreateParams = { + command: 'create', + path: filePath, + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const content = await fs.readFileText(filePath) + assert.strictEqual(content, '') + + assert.deepStrictEqual(output, expectedOutput) + }) + }) + + describe('handleStrReplace', async function () { + before(async function () { + testFolder = await TestFolder.create() + }) + + it('replaces a single occurrence of a string', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + await fs.writeFile(filePath, 'Hello World') + + const params: StrReplaceParams = { + command: 'strReplace', + path: filePath, + oldStr: 'Hello', + newStr: 'Goodbye', + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const content = await fs.readFileText(filePath) + assert.strictEqual(content, 'Goodbye World') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('throws error when no matches are found', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + + const params: StrReplaceParams = { + command: 'strReplace', + path: filePath, + oldStr: 'Invalid', + newStr: 'Goodbye', + } + + const fsWrite = new FsWrite(params) + await assert.rejects(() => fsWrite.invoke(process.stdout), /No occurrences of "Invalid" were found/) + }) + + it('throws error when multiple matches are found', async function () { + const filePath = path.join(testFolder.path, 'file2.txt') + await fs.writeFile(filePath, 'Hello Hello World') + + const params: StrReplaceParams = { + command: 'strReplace', + path: filePath, + oldStr: 'Hello', + newStr: 'Goodbye', + } + + const fsWrite = new FsWrite(params) + await assert.rejects( + () => fsWrite.invoke(process.stdout), + /2 occurrences of oldStr were found when only 1 is expected/ + ) + }) + + it('handles regular expression special characters correctly', async function () { + const filePath = path.join(testFolder.path, 'file3.txt') + await fs.writeFile(filePath, 'Text with special chars: .*+?^${}()|[]\\') + + const params: StrReplaceParams = { + command: 'strReplace', + path: filePath, + oldStr: '.*+?^${}()|[]\\', + newStr: 'REPLACED', + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const content = await fs.readFileText(filePath) + assert.strictEqual(content, 'Text with special chars: REPLACED') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('preserves whitespace and newlines during replacement', async function () { + const filePath = path.join(testFolder.path, 'file4.txt') + await fs.writeFile(filePath, 'Line 1\n Indented line\nLine 3') + + const params: StrReplaceParams = { + command: 'strReplace', + path: filePath, + oldStr: ' Indented line\n', + newStr: ' Double indented\n', + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const content = await fs.readFileText(filePath) + assert.strictEqual(content, 'Line 1\n Double indented\nLine 3') + + assert.deepStrictEqual(output, expectedOutput) + }) + }) + + describe('handleInsert', function () { + before(async function () { + testFolder = await TestFolder.create() + }) + + it('inserts text after the specified line number', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + await fs.writeFile(filePath, 'Line 1\nLine 2\nLine 3\nLine 4') + + const params: InsertParams = { + command: 'insert', + path: filePath, + insertLine: 2, + newStr: 'New Line', + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'Line 1\nLine 2\nNew Line\nLine 3\nLine 4') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('inserts text at the beginning when line number is 0', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + const params: InsertParams = { + command: 'insert', + path: filePath, + insertLine: 0, + newStr: 'New First Line', + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'New First Line\nLine 1\nLine 2\nNew Line\nLine 3\nLine 4') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('inserts text at the end when line number exceeds file length', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + const params: InsertParams = { + command: 'insert', + path: filePath, + insertLine: 10, + newStr: 'New Last Line', + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'New First Line\nLine 1\nLine 2\nNew Line\nLine 3\nLine 4\nNew Last Line') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('handles insertion into an empty file', async function () { + const filePath = path.join(testFolder.path, 'file2.txt') + await fs.writeFile(filePath, '') + + const params: InsertParams = { + command: 'insert', + path: filePath, + insertLine: 0, + newStr: 'First Line', + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'First Line\n') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('handles negative line numbers by inserting at the beginning', async function () { + const filePath = path.join(testFolder.path, 'file2.txt') + + const params: InsertParams = { + command: 'insert', + path: filePath, + insertLine: -1, + newStr: 'New First Line', + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'New First Line\nFirst Line\n') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('throws error when file does not exist', async function () { + const filePath = path.join(testFolder.path, 'nonexistent.txt') + + const params: InsertParams = { + command: 'insert', + path: filePath, + insertLine: 1, + newStr: 'New Line', + } + + const fsWrite = new FsWrite(params) + await assert.rejects(() => fsWrite.invoke(process.stdout), /no such file or directory/) + }) + }) + + describe('handleAppend', function () { + before(async function () { + testFolder = await TestFolder.create() + }) + + it('appends text to the end of a file', async function () { + const filePath = path.join(testFolder.path, 'file1.txt') + await fs.writeFile(filePath, 'Line 1\nLine 2\nLine 3\n') + + const params: AppendParams = { + command: 'append', + path: filePath, + newStr: 'Line 4', + } + + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'Line 1\nLine 2\nLine 3\nLine 4') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('adds a newline before appending if file does not end with one', async function () { + const filePath = path.join(testFolder.path, 'file2.txt') + await fs.writeFile(filePath, 'Line 1\nLine 2\nLine 3') + + const params: AppendParams = { + command: 'append', + path: filePath, + newStr: 'Line 4', + } + + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'Line 1\nLine 2\nLine 3\nLine 4') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('appends to an empty file', async function () { + const filePath = path.join(testFolder.path, 'file3.txt') + await fs.writeFile(filePath, '') + + const params: AppendParams = { + command: 'append', + path: filePath, + newStr: 'Line 1', + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'Line 1') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('appends multiple lines correctly', async function () { + const filePath = path.join(testFolder.path, 'file3.txt') + + const params: AppendParams = { + command: 'append', + path: filePath, + newStr: 'Line 2\nLine 3', + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'Line 1\nLine 2\nLine 3') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('handles appending empty string', async function () { + const filePath = path.join(testFolder.path, 'file3.txt') + + const params: AppendParams = { + command: 'append', + path: filePath, + newStr: '', + } + const fsWrite = new FsWrite(params) + const output = await fsWrite.invoke(process.stdout) + + const newContent = await fs.readFileText(filePath) + assert.strictEqual(newContent, 'Line 1\nLine 2\nLine 3\n') + + assert.deepStrictEqual(output, expectedOutput) + }) + + it('throws error when file does not exist', async function () { + const filePath = path.join(testFolder.path, 'nonexistent.txt') + + const params: AppendParams = { + command: 'append', + path: filePath, + newStr: 'New Line', + } + + const fsWrite = new FsWrite(params) + await assert.rejects(() => fsWrite.invoke(process.stdout), /no such file or directory/) + }) + }) +}) diff --git a/packages/core/src/test/codewhispererChat/tools/grepSearch.test.ts b/packages/core/src/test/codewhispererChat/tools/grepSearch.test.ts new file mode 100644 index 00000000000..d670b51ef46 --- /dev/null +++ b/packages/core/src/test/codewhispererChat/tools/grepSearch.test.ts @@ -0,0 +1,561 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import sinon from 'sinon' +import * as vscode from 'vscode' +import { GrepSearch, GrepSearchParams } from '../../../codewhispererChat/tools/grepSearch' +import { ChildProcess } from '../../../shared/utilities/processUtils' +import { Writable } from 'stream' +import { OutputKind } from '../../../codewhispererChat/tools/toolShared' +import fs from '../../../shared/fs/fs' + +describe('GrepSearch', () => { + let sandbox: sinon.SinonSandbox + let mockUpdates: Writable + const mockWorkspacePath = '/mock/workspace' + + beforeEach(() => { + sandbox = sinon.createSandbox() + + // Create a mock Writable stream for updates + mockUpdates = new Writable({ + write: (chunk, encoding, callback) => { + callback() + }, + }) + sandbox.spy(mockUpdates, 'write') + sandbox.spy(mockUpdates, 'end') + + // Mock workspace folders + sandbox.stub(vscode.workspace, 'workspaceFolders').value([ + { + uri: { fsPath: mockWorkspacePath } as vscode.Uri, + name: 'mockWorkspace', + index: 0, + }, + ]) + + // Mock fs.existsDir to always return true + sandbox.stub(fs, 'existsDir').resolves(true) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('constructor', () => { + it('should initialize with default values', () => { + const params: GrepSearchParams = { + query: 'test-query', + } + const grepSearch = new GrepSearch(params) + + assert.strictEqual((grepSearch as any).query, 'test-query') + assert.strictEqual((grepSearch as any).caseSensitive, false) + assert.strictEqual((grepSearch as any).excludePattern, undefined) + assert.strictEqual((grepSearch as any).includePattern, undefined) + assert.strictEqual((grepSearch as any).path, mockWorkspacePath) + }) + + it('should initialize with provided values', () => { + const params: GrepSearchParams = { + query: 'test-query', + caseSensitive: true, + excludePattern: '*.log', + includePattern: '*.ts', + path: '/custom/path', + } + + const grepSearch = new GrepSearch(params) + + assert.strictEqual((grepSearch as any).query, 'test-query') + assert.strictEqual((grepSearch as any).caseSensitive, true) + assert.strictEqual((grepSearch as any).excludePattern, '*.log') + assert.strictEqual((grepSearch as any).includePattern, '*.ts') + assert.strictEqual((grepSearch as any).path, '/custom/path') + }) + }) + + describe('getSearchDirectory', () => { + it('should use provided path when available', () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + path: '/custom/path', + }) + + const result = (grepSearch as any).getSearchDirectory('/custom/path') + assert.strictEqual(result, '/custom/path') + }) + + it('should use workspace folder when path is not provided', () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + }) + + const result = (grepSearch as any).getSearchDirectory() + assert.strictEqual(result, mockWorkspacePath) + }) + }) + + describe('validate', () => { + it('should throw an error if query is empty', async () => { + const grepSearch = new GrepSearch({ query: '' }) + await assert.rejects(async () => await grepSearch.validate(), /Grep search query cannot be empty/) + }) + + it('should throw an error if query is only whitespace', async () => { + const grepSearch = new GrepSearch({ query: ' ' }) + + await assert.rejects(async () => await grepSearch.validate(), /Grep search query cannot be empty/) + }) + + it('should throw an error if path does not exist', async () => { + sandbox.restore() + sandbox = sinon.createSandbox() + sandbox.stub(fs, 'existsDir').resolves(false) + + const grepSearch = new GrepSearch({ + query: 'test-query', + path: '/non/existent/path', + }) + + await assert.rejects( + async () => await grepSearch.validate(), + /Path: "\/non\/existent\/path" does not exist or cannot be accessed/ + ) + }) + + it('should pass validation with valid query and path', async () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + path: '/valid/path', + }) + await assert.doesNotReject(async () => await grepSearch.validate()) + }) + }) + + describe('queueDescription', () => { + it('should write description to updates stream', () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + }) + + grepSearch.queueDescription(mockUpdates) + + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledWith( + // eslint-disable-next-line @typescript-eslint/unbound-method + mockUpdates.write as sinon.SinonSpy, + `Grepping for "test-query" in directory: /test/path` + ) + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledOnce(mockUpdates.end as sinon.SinonSpy) + }) + }) + + describe('invoke', () => { + let grepSearch: GrepSearch + beforeEach(async () => { + grepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + }) + await grepSearch.validate() + // Setup ChildProcess run method + const mockRun = sandbox.stub() + mockRun.resolves({ stdout: 'search-results', stderr: '', exitCode: 0 }) + sandbox.stub(ChildProcess.prototype, 'run').callsFake(mockRun) + + // Mock processRipgrepOutput + sandbox.stub(grepSearch as any, 'processRipgrepOutput').returns({ + sanitizedOutput: 'processed-results', + totalMatchCount: 5, + }) + }) + + it('should execute ripgrep and return results', async () => { + const result = await grepSearch.invoke() + assert.deepStrictEqual(result, { + output: { + kind: OutputKind.Text, + content: 'processed-results', + }, + }) + }) + + it('should write updates to the provided stream', async () => { + await grepSearch.invoke(mockUpdates) + + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledWith(mockUpdates.write as sinon.SinonSpy, '\n\n5 matches found:\n\n') + // eslint-disable-next-line @typescript-eslint/unbound-method + sinon.assert.calledWith(mockUpdates.write as sinon.SinonSpy, 'processed-results') + }) + + it('should throw an error if ripgrep execution fails', async () => { + sandbox.restore() + sandbox = sinon.createSandbox() + // Make ChildProcess.run throw an error + sandbox.stub(ChildProcess.prototype, 'run').rejects(new Error('Command failed')) + grepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + }) + await assert.rejects(async () => await grepSearch.invoke(), /Failed to search/) + }) + }) + + describe('executeRipgrep', () => { + beforeEach(async () => { + // Setup the run method to return a successful result + const mockRun = sandbox.stub().resolves({ stdout: 'search-results', stderr: '', exitCode: 0 }) + sandbox.stub(ChildProcess.prototype, 'run').callsFake(mockRun) + }) + + it('should use case insensitive search by default', async () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + }) + + // Mock processRipgrepOutput + sandbox.stub(grepSearch as any, 'processRipgrepOutput').returns({ + sanitizedOutput: 'processed-results', + totalMatchCount: 5, + }) + + // Get the existing stub + // eslint-disable-next-line @typescript-eslint/unbound-method + const runStub = ChildProcess.prototype.run as sinon.SinonStub + + await (grepSearch as any).executeRipgrep() + + // Check the arguments passed to the stub + const args = runStub.getCall(0).thisValue.args + assert.ok(args.includes('-i'), 'Should include -i flag for case insensitive search') + }) + + it('should use case sensitive search when specified', async () => { + // Create a new sandbox for this test + sandbox.restore() + sandbox = sinon.createSandbox() + + // Setup the stub again for this test + const mockRun = sandbox.stub().resolves({ stdout: 'search-results', stderr: '', exitCode: 0 }) + sandbox.stub(ChildProcess.prototype, 'run').callsFake(mockRun) + + const caseSensitiveGrepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + caseSensitive: true, + }) + + // Re-stub processRipgrepOutput for the new instance + sandbox.stub(caseSensitiveGrepSearch as any, 'processRipgrepOutput').returns({ + sanitizedOutput: 'processed-results', + totalMatchCount: 5, + }) + + await (caseSensitiveGrepSearch as any).executeRipgrep() + + // Get the stub after it's been called + // eslint-disable-next-line @typescript-eslint/unbound-method + const runStub = ChildProcess.prototype.run as sinon.SinonStub + const args = runStub.getCall(0).thisValue.args + assert.ok(!args.includes('-i'), 'Should not include -i flag for case sensitive search') + }) + + it('should add include pattern when specified', async () => { + // Create a new sandbox for this test + sandbox.restore() + sandbox = sinon.createSandbox() + + // Setup the stub again for this test + const mockRun = sandbox.stub().resolves({ stdout: 'search-results', stderr: '', exitCode: 0 }) + sandbox.stub(ChildProcess.prototype, 'run').callsFake(mockRun) + + const includeGrepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + includePattern: '*.ts', + }) + + // Re-stub processRipgrepOutput for the new instance + sandbox.stub(includeGrepSearch as any, 'processRipgrepOutput').returns({ + sanitizedOutput: 'processed-results', + totalMatchCount: 5, + }) + + await (includeGrepSearch as any).executeRipgrep() + + // Get the stub after it's been called + // eslint-disable-next-line @typescript-eslint/unbound-method + const runStub = ChildProcess.prototype.run as sinon.SinonStub + const args = runStub.getCall(0).thisValue.args + const globIndex = args.indexOf('--glob') + assert.ok(globIndex !== -1, 'Should include --glob flag') + assert.strictEqual(args[globIndex + 1], '*.ts', 'Should include the pattern') + }) + + it('should add exclude pattern when specified', async () => { + // Create a new sandbox for this test + sandbox.restore() + sandbox = sinon.createSandbox() + + // Setup the stub again for this test + const mockRun = sandbox.stub().resolves({ stdout: 'search-results', stderr: '', exitCode: 0 }) + sandbox.stub(ChildProcess.prototype, 'run').callsFake(mockRun) + + const excludeGrepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + excludePattern: '*.log', + }) + + // Re-stub processRipgrepOutput for the new instance + sandbox.stub(excludeGrepSearch as any, 'processRipgrepOutput').returns({ + sanitizedOutput: 'processed-results', + totalMatchCount: 5, + }) + + await (excludeGrepSearch as any).executeRipgrep() + + // Get the stub after it's been called + // eslint-disable-next-line @typescript-eslint/unbound-method + const runStub = ChildProcess.prototype.run as sinon.SinonStub + const args = runStub.getCall(0).thisValue.args + const globIndex = args.indexOf('--glob') + assert.ok(globIndex !== -1, 'Should include --glob flag') + assert.strictEqual(args[globIndex + 1], '!*.log', 'Should include the negated pattern') + }) + + it('should handle multiple include patterns', async () => { + // Create a new sandbox for this test + sandbox.restore() + sandbox = sinon.createSandbox() + + // Setup the stub again for this test + const mockRun = sandbox.stub().resolves({ stdout: 'search-results', stderr: '', exitCode: 0 }) + sandbox.stub(ChildProcess.prototype, 'run').callsFake(mockRun) + + const multiIncludeGrepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + includePattern: '*.ts, *.js', + }) + + // Re-stub processRipgrepOutput for the new instance + sandbox.stub(multiIncludeGrepSearch as any, 'processRipgrepOutput').returns({ + sanitizedOutput: 'processed-results', + totalMatchCount: 5, + }) + + await (multiIncludeGrepSearch as any).executeRipgrep() + + // Get the stub after it's been called + // eslint-disable-next-line @typescript-eslint/unbound-method + const runStub = ChildProcess.prototype.run as sinon.SinonStub + const args = runStub.getCall(0).thisValue.args + + // Check for both patterns + const globIndices = args.reduce((indices: number[], arg: string, index: number) => { + if (arg === '--glob') { + indices.push(index) + } + return indices + }, []) + + assert.strictEqual(globIndices.length, 2, 'Should have two --glob flags') + assert.strictEqual(args[globIndices[0] + 1], '*.ts', 'First pattern should be *.ts') + assert.strictEqual(args[globIndices[1] + 1], '*.js', 'Second pattern should be *.js') + }) + + it('should handle multiple exclude patterns', async () => { + // Create a new sandbox for this test + sandbox.restore() + sandbox = sinon.createSandbox() + + // Setup the stub again for this test + const mockRun = sandbox.stub().resolves({ stdout: 'search-results', stderr: '', exitCode: 0 }) + sandbox.stub(ChildProcess.prototype, 'run').callsFake(mockRun) + + const multiExcludeGrepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + excludePattern: '*.log, *.tmp', + }) + + // Re-stub processRipgrepOutput for the new instance + sandbox.stub(multiExcludeGrepSearch as any, 'processRipgrepOutput').returns({ + sanitizedOutput: 'processed-results', + totalMatchCount: 5, + }) + + await (multiExcludeGrepSearch as any).executeRipgrep() + + // Get the stub after it's been called + // eslint-disable-next-line @typescript-eslint/unbound-method + const runStub = ChildProcess.prototype.run as sinon.SinonStub + const args = runStub.getCall(0).thisValue.args + + // Check for both patterns + const globIndices = args.reduce((indices: number[], arg: string, index: number) => { + if (arg === '--glob') { + indices.push(index) + } + return indices + }, []) + + assert.strictEqual(globIndices.length, 2, 'Should have two --glob flags') + assert.strictEqual(args[globIndices[0] + 1], '!*.log', 'First pattern should be !*.log') + assert.strictEqual(args[globIndices[1] + 1], '!*.tmp', 'Second pattern should be !*.tmp') + }) + + it('should handle ripgrep exit code 1 (no matches)', async () => { + sandbox.restore() + sandbox = sinon.createSandbox() + + // Setup ChildProcess to simulate exit code 1 (no matches found) + const error = new Error() + error.name = 'ChildProcessError' + ;(error as any).code = 1 + + sandbox.stub(ChildProcess.prototype, 'run').rejects(error) + + const grepSearch = new GrepSearch({ + query: 'no-matches-query', + path: '/test/path', + }) + + // Mock processRipgrepOutput for empty results + sandbox.stub(grepSearch as any, 'processRipgrepOutput').returns({ + sanitizedOutput: 'No matches found.', + totalMatchCount: 0, + }) + + // This should not throw an error since code 1 is handled in rejectOnErrorCode + const result = await grepSearch.invoke() + + // Should still return a valid output + assert.deepStrictEqual(result.output.kind, OutputKind.Text) + assert.deepStrictEqual(result.output.content, 'No matches found.') + }) + }) + + describe('processRipgrepOutput', () => { + let grepSearch: GrepSearch + + beforeEach(() => { + grepSearch = new GrepSearch({ + query: 'test-query', + path: '/test/path', + }) + + // Mock vscode.Uri.file and with + sandbox.stub(vscode.Uri, 'file').callsFake((filePath) => { + return { + with: (options: any) => { + return { + toString: () => `file://${filePath}#${options.fragment}`, + } + }, + toString: () => `file://${filePath}`, + } as any + }) + }) + + it('should handle empty output', () => { + const { sanitizedOutput, totalMatchCount } = (grepSearch as any).processRipgrepOutput('') + + assert.strictEqual(sanitizedOutput, 'No matches found.') + assert.strictEqual(totalMatchCount, 0) + }) + + it('should process ripgrep output and group by file', () => { + const mockOutput = + '/test/file1.ts:10:some match content\n' + + '/test/file1.ts:20:another match\n' + + '/test/file2.ts:5:match in another file' + + const { sanitizedOutput, totalMatchCount } = (grepSearch as any).processRipgrepOutput(mockOutput) + + assert.strictEqual(totalMatchCount, 3) + + // Check that output contains details tags + assert.ok(sanitizedOutput.includes('
')) + assert.ok(sanitizedOutput.includes('
')) + + // Check that output contains file names + assert.ok(sanitizedOutput.includes('file1.ts - match count: (2)')) + assert.ok(sanitizedOutput.includes('file2.ts - match count: (1)')) + + // Check that output contains line numbers as links + assert.ok(sanitizedOutput.includes('[Line 10]')) + assert.ok(sanitizedOutput.includes('[Line 20]')) + assert.ok(sanitizedOutput.includes('[Line 5]')) + + // Check that files are sorted by match count (most matches first) + const file1Index = sanitizedOutput.indexOf('file1.ts') + const file2Index = sanitizedOutput.indexOf('file2.ts') + assert.ok(file1Index < file2Index, 'Files should be sorted by match count') + }) + + it('should handle malformed output lines', () => { + const mockOutput = + '/test/file1.ts:10:some match content\n' + + 'malformed line without colon\n' + + '/test/file2.ts:5:match in another file' + + const { sanitizedOutput, totalMatchCount } = (grepSearch as any).processRipgrepOutput(mockOutput) + + assert.strictEqual(totalMatchCount, 2) + + // Check that output contains details tags + assert.ok(sanitizedOutput.includes('
')) + assert.ok(sanitizedOutput.includes('
')) + + // Check that output contains file names + assert.ok(sanitizedOutput.includes('file1.ts - match count: (1)')) + assert.ok(sanitizedOutput.includes('file2.ts - match count: (1)')) + + // Check that malformed line was skipped + assert.ok(!sanitizedOutput.includes('malformed')) + }) + }) + + describe('createOutput', () => { + it('should create output with content', () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + }) + + const output = (grepSearch as any).createOutput('test content') + + assert.deepStrictEqual(output, { + output: { + kind: OutputKind.Text, + content: 'test content', + }, + }) + }) + + it('should create output with default message when content is empty', () => { + const grepSearch = new GrepSearch({ + query: 'test-query', + }) + + const output = (grepSearch as any).createOutput('') + + assert.deepStrictEqual(output, { + output: { + kind: OutputKind.Text, + content: 'No matches found.', + }, + }) + }) + }) +}) diff --git a/packages/core/src/test/codewhispererChat/tools/listDirectory.test.ts b/packages/core/src/test/codewhispererChat/tools/listDirectory.test.ts new file mode 100644 index 00000000000..19642f36f39 --- /dev/null +++ b/packages/core/src/test/codewhispererChat/tools/listDirectory.test.ts @@ -0,0 +1,123 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'assert' +import { ListDirectory } from '../../../codewhispererChat/tools/listDirectory' +import { TestFolder } from '../../testUtil' +import path from 'path' +import * as vscode from 'vscode' +import sinon from 'sinon' + +describe('ListDirectory Tool', () => { + let testFolder: TestFolder + + before(async () => { + testFolder = await TestFolder.create() + }) + + afterEach(() => { + sinon.restore() + }) + + it('throws if path is empty', async () => { + const listDirectory = new ListDirectory({ path: '', maxDepth: 0 }) + await assert.rejects(listDirectory.validate(), /Path cannot be empty/i, 'Expected an error about empty path') + }) + + it('throws if maxDepth is negative', async () => { + const listDirectory = new ListDirectory({ path: '~', maxDepth: -1 }) + await assert.rejects( + listDirectory.validate(), + /MaxDepth cannot be negative/i, + 'Expected an error about negative maxDepth' + ) + }) + + it('lists directory contents', async () => { + await testFolder.mkdir('subfolder') + await testFolder.write('fileA.txt', 'fileA content') + + const listDirectory = new ListDirectory({ path: testFolder.path, maxDepth: 0 }) + await listDirectory.validate() + const result = await listDirectory.invoke(process.stdout) + + const lines = result.output.content.split('\n') + const hasFileA = lines.some((line: string | string[]) => line.includes('[FILE] ') && line.includes('fileA.txt')) + const hasSubfolder = lines.some( + (line: string | string[]) => line.includes('[DIR] ') && line.includes('subfolder') + ) + + assert.ok(hasFileA, 'Should list fileA.txt in the directory output') + assert.ok(hasSubfolder, 'Should list the subfolder in the directory output') + }) + + it('lists directory contents recursively', async () => { + await testFolder.mkdir('subfolder') + await testFolder.write('fileA.txt', 'fileA content') + await testFolder.write(path.join('subfolder', 'fileB.md'), '# fileB') + + const listDirectory = new ListDirectory({ path: testFolder.path }) + await listDirectory.validate() + const result = await listDirectory.invoke(process.stdout) + + const lines = result.output.content.split('\n') + const hasFileA = lines.some((line: string | string[]) => line.includes('[FILE] ') && line.includes('fileA.txt')) + const hasSubfolder = lines.some( + (line: string | string[]) => line.includes('[DIR] ') && line.includes('subfolder') + ) + const hasFileB = lines.some((line: string | string[]) => line.includes('[FILE] ') && line.includes('fileB.md')) + + assert.ok(hasFileA, 'Should list fileA.txt in the directory output') + assert.ok(hasSubfolder, 'Should list the subfolder in the directory output') + assert.ok(hasFileB, 'Should list fileB.md in the subfolder in the directory output') + }) + + it('throws error if path does not exist', async () => { + const missingPath = path.join(testFolder.path, 'no_such_file.txt') + const listDirectory = new ListDirectory({ path: missingPath, maxDepth: 0 }) + + await assert.rejects( + listDirectory.validate(), + /does not exist or cannot be accessed/i, + 'Expected an error indicating the path does not exist' + ) + }) + + it('expands ~ path', async () => { + const listDirectory = new ListDirectory({ path: '~', maxDepth: 0 }) + await listDirectory.validate() + const result = await listDirectory.invoke(process.stdout) + + assert.strictEqual(result.output.kind, 'text') + assert.ok(result.output.content.length > 0) + }) + + it('should require acceptance if fsPath is outside the workspace', () => { + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + const listDir = new ListDirectory({ path: '/not/in/workspace/dir', maxDepth: 0 }) + const result = listDir.requiresAcceptance() + assert.equal( + result.requiresAcceptance, + true, + 'Expected requiresAcceptance to be true for a path outside the workspace' + ) + workspaceStub.restore() + }) + + it('should not require acceptance if fsPath is inside the workspace', () => { + const workspaceStub = sinon + .stub(vscode.workspace, 'workspaceFolders') + .value([{ uri: { fsPath: '/workspace/folder' } } as any]) + const listDir = new ListDirectory({ path: '/workspace/folder/mydir', maxDepth: 0 }) + const result = listDir.requiresAcceptance() + assert.equal( + result.requiresAcceptance, + false, + 'Expected requiresAcceptance to be false for a path inside the workspace' + ) + workspaceStub.restore() + }) +}) diff --git a/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts b/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts new file mode 100644 index 00000000000..903ceb7ab4d --- /dev/null +++ b/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts @@ -0,0 +1,373 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import { Writable } from 'stream' +import { sanitizePath, OutputKind, InvokeOutput } from '../../../codewhispererChat/tools/toolShared' +import { ToolUtils, Tool, ToolType } from '../../../codewhispererChat/tools/toolUtils' +import { FsRead } from '../../../codewhispererChat/tools/fsRead' +import { FsWrite } from '../../../codewhispererChat/tools/fsWrite' +import { ExecuteBash } from '../../../codewhispererChat/tools/executeBash' +import { ToolUse } from '@amzn/codewhisperer-streaming' +import path from 'path' +import fs from '../../../shared/fs/fs' +import { ListDirectory } from '../../../codewhispererChat/tools/listDirectory' + +describe('ToolUtils', function () { + let sandbox: sinon.SinonSandbox + let mockFsRead: sinon.SinonStubbedInstance + let mockFsWrite: sinon.SinonStubbedInstance + let mockExecuteBash: sinon.SinonStubbedInstance + let mockListDirectory: sinon.SinonStubbedInstance + let mockWritable: sinon.SinonStubbedInstance + + beforeEach(function () { + sandbox = sinon.createSandbox() + mockFsRead = sandbox.createStubInstance(FsRead) + mockFsWrite = sandbox.createStubInstance(FsWrite) + mockExecuteBash = sandbox.createStubInstance(ExecuteBash) + mockListDirectory = sandbox.createStubInstance(ListDirectory) + mockWritable = { + write: sandbox.stub(), + } as unknown as sinon.SinonStubbedInstance + ;(mockFsRead.requiresAcceptance as sinon.SinonStub).returns({ requiresAcceptance: false }) + ;(mockListDirectory.requiresAcceptance as sinon.SinonStub).returns({ requiresAcceptance: false }) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('displayName', function () { + it('returns correct display name for FsRead tool', function () { + const tool: Tool = { type: ToolType.FsRead, tool: mockFsRead as unknown as FsRead } + assert.strictEqual(ToolUtils.displayName(tool), 'Read from filesystem') + }) + + it('returns correct display name for FsWrite tool', function () { + const tool: Tool = { type: ToolType.FsWrite, tool: mockFsWrite as unknown as FsWrite } + assert.strictEqual(ToolUtils.displayName(tool), 'Write to filesystem') + }) + + it('returns correct display name for ExecuteBash tool', function () { + const tool: Tool = { type: ToolType.ExecuteBash, tool: mockExecuteBash as unknown as ExecuteBash } + assert.strictEqual(ToolUtils.displayName(tool), 'Execute shell command') + }) + + it('returns correct display name for ListDirectory tool', function () { + const tool: Tool = { type: ToolType.ListDirectory, tool: mockListDirectory as unknown as ListDirectory } + assert.strictEqual(ToolUtils.displayName(tool), 'List directory from filesystem') + }) + }) + + describe('requiresAcceptance', function () { + it('returns false for FsRead tool', function () { + const tool: Tool = { type: ToolType.FsRead, tool: mockFsRead as unknown as FsRead } + assert.strictEqual(ToolUtils.requiresAcceptance(tool).requiresAcceptance, false) + }) + + it('returns false for FsWrite tool', function () { + const tool: Tool = { type: ToolType.FsWrite, tool: mockFsWrite as unknown as FsWrite } + assert.strictEqual(ToolUtils.requiresAcceptance(tool).requiresAcceptance, false) + }) + + it('delegates to the tool for ExecuteBash', function () { + mockExecuteBash.requiresAcceptance.returns({ requiresAcceptance: true }) + const tool: Tool = { type: ToolType.ExecuteBash, tool: mockExecuteBash as unknown as ExecuteBash } + assert.strictEqual(ToolUtils.requiresAcceptance(tool).requiresAcceptance, true) + + mockExecuteBash.requiresAcceptance.returns({ requiresAcceptance: false }) + assert.strictEqual(ToolUtils.requiresAcceptance(tool).requiresAcceptance, false) + + assert(mockExecuteBash.requiresAcceptance.calledTwice) + }) + + it('returns false for ListDirectory tool', function () { + const tool: Tool = { type: ToolType.ListDirectory, tool: mockListDirectory as unknown as ListDirectory } + assert.strictEqual(ToolUtils.requiresAcceptance(tool).requiresAcceptance, false) + }) + }) + + describe('invoke', function () { + it('delegates to FsRead tool invoke method', async function () { + const expectedOutput: InvokeOutput = { + output: { + kind: OutputKind.Text, + content: 'test content', + }, + } + mockFsRead.invoke.resolves(expectedOutput) + + const tool: Tool = { type: ToolType.FsRead, tool: mockFsRead as unknown as FsRead } + const result = await ToolUtils.invoke(tool, mockWritable as unknown as Writable) + + assert.deepStrictEqual(result, expectedOutput) + assert(mockFsRead.invoke.calledOnceWith(mockWritable as unknown as Writable | undefined)) + }) + + it('delegates to FsWrite tool invoke method', async function () { + const expectedOutput: InvokeOutput = { + output: { + kind: OutputKind.Text, + content: 'write success', + }, + } + mockFsWrite.invoke.resolves(expectedOutput) + + const tool: Tool = { type: ToolType.FsWrite, tool: mockFsWrite as unknown as FsWrite } + const result = await ToolUtils.invoke(tool, mockWritable as unknown as Writable) + + assert.deepStrictEqual(result, expectedOutput) + assert(mockFsWrite.invoke.calledOnceWith(mockWritable as unknown as Writable | undefined)) + }) + + it('delegates to ExecuteBash tool invoke method', async function () { + const expectedOutput: InvokeOutput = { + output: { + kind: OutputKind.Json, + content: '{"stdout":"command output","exit_status":"0"}', + }, + } + mockExecuteBash.invoke.resolves(expectedOutput) + + const tool: Tool = { type: ToolType.ExecuteBash, tool: mockExecuteBash as unknown as ExecuteBash } + const result = await ToolUtils.invoke(tool, mockWritable as unknown as Writable) + + assert.deepStrictEqual(result, expectedOutput) + assert(mockExecuteBash.invoke.calledOnceWith(mockWritable as unknown as Writable | undefined)) + }) + + it('delegates to ListDirectory tool invoke method', async function () { + const expectedOutput: InvokeOutput = { + output: { + kind: OutputKind.Text, + content: 'test content', + }, + } + mockListDirectory.invoke.resolves(expectedOutput) + + const tool: Tool = { type: ToolType.ListDirectory, tool: mockListDirectory as unknown as ListDirectory } + const result = await ToolUtils.invoke(tool, mockWritable as unknown as Writable) + + assert.deepStrictEqual(result, expectedOutput) + assert(mockListDirectory.invoke.calledOnceWith(mockWritable as unknown as Writable | undefined)) + }) + }) + + describe('validateOutput', function () { + it('does not throw error if output is within size limit', function () { + const output: InvokeOutput = { + output: { + kind: OutputKind.Text, + content: 'a'.repeat(150_000), + }, + } + assert.doesNotThrow(() => ToolUtils.validateOutput(output)) + }) + it('throws error if output exceeds max size', function () { + const output: InvokeOutput = { + output: { + kind: OutputKind.Text, + content: 'a'.repeat(200_001), // 200,001 characters + }, + } + assert.throws(() => ToolUtils.validateOutput(output), { + name: 'Error', + message: 'Tool output exceeds maximum character limit of 200000', + }) + }) + }) + + describe('queueDescription', function () { + // TODO: Adding "void" to the following tests for the current implementation but in the next followup PR I will fix this issue. + it('delegates to FsRead tool queueDescription method', function () { + const tool: Tool = { type: ToolType.FsRead, tool: mockFsRead as unknown as FsRead } + void ToolUtils.queueDescription(tool, mockWritable as unknown as Writable) + + assert(mockFsRead.queueDescription.calledOnceWith(mockWritable)) + }) + + it('delegates to FsWrite tool queueDescription method', function () { + const tool: Tool = { type: ToolType.FsWrite, tool: mockFsWrite as unknown as FsWrite } + void ToolUtils.queueDescription(tool, mockWritable as unknown as Writable) + + assert(mockFsWrite.queueDescription.calledOnceWith(mockWritable)) + }) + + it('delegates to ExecuteBash tool queueDescription method', function () { + const tool: Tool = { type: ToolType.ExecuteBash, tool: mockExecuteBash as unknown as ExecuteBash } + void ToolUtils.queueDescription(tool, mockWritable as unknown as Writable) + + assert(mockExecuteBash.queueDescription.calledOnceWith(mockWritable)) + }) + + it('delegates to ListDirectory tool queueDescription method', function () { + const tool: Tool = { type: ToolType.ListDirectory, tool: mockListDirectory as unknown as ListDirectory } + void ToolUtils.queueDescription(tool, mockWritable as unknown as Writable) + + assert(mockListDirectory.queueDescription.calledOnceWith(mockWritable)) + }) + }) + + describe('validate', function () { + it('delegates to FsRead tool validate method', async function () { + mockFsRead.validate.resolves() + + const tool: Tool = { type: ToolType.FsRead, tool: mockFsRead as unknown as FsRead } + await ToolUtils.validate(tool) + + assert(mockFsRead.validate.calledOnce) + }) + + it('delegates to FsWrite tool validate method', async function () { + mockFsWrite.validate.resolves() + + const tool: Tool = { type: ToolType.FsWrite, tool: mockFsWrite as unknown as FsWrite } + await ToolUtils.validate(tool) + + assert(mockFsWrite.validate.calledOnce) + }) + + it('delegates to ExecuteBash tool validate method', async function () { + mockExecuteBash.validate.resolves() + + const tool: Tool = { type: ToolType.ExecuteBash, tool: mockExecuteBash as unknown as ExecuteBash } + await ToolUtils.validate(tool) + + assert(mockExecuteBash.validate.calledOnce) + }) + + it('delegates to ListDirectory tool validate method', async function () { + mockListDirectory.validate.resolves() + + const tool: Tool = { type: ToolType.ListDirectory, tool: mockListDirectory as unknown as ListDirectory } + await ToolUtils.validate(tool) + + assert(mockListDirectory.validate.calledOnce) + }) + }) + + describe('tryFromToolUse', function () { + it('creates FsRead tool from ToolUse', function () { + const toolUse: ToolUse = { + toolUseId: 'test-id', + name: ToolType.FsRead, + input: { path: '/test/path', mode: 'Line' }, + } + + const result = ToolUtils.tryFromToolUse(toolUse) + + assert.strictEqual('type' in result, true) + if ('type' in result) { + assert.strictEqual(result.type, ToolType.FsRead) + assert(result.tool instanceof FsRead) + } + }) + + it('creates FsWrite tool from ToolUse', function () { + const toolUse: ToolUse = { + toolUseId: 'test-id', + name: ToolType.FsWrite, + input: { command: 'create', path: '/test/path', file_text: 'content' }, + } + + const result = ToolUtils.tryFromToolUse(toolUse) + + assert.strictEqual('type' in result, true) + if ('type' in result) { + assert.strictEqual(result.type, ToolType.FsWrite) + assert(result.tool instanceof FsWrite) + } + }) + + it('creates ExecuteBash tool from ToolUse', function () { + const toolUse: ToolUse = { + toolUseId: 'test-id', + name: ToolType.ExecuteBash, + input: { command: 'ls -la' }, + } + + const result = ToolUtils.tryFromToolUse(toolUse) + + assert.strictEqual('type' in result, true) + if ('type' in result) { + assert.strictEqual(result.type, ToolType.ExecuteBash) + assert(result.tool instanceof ExecuteBash) + } + }) + + it('creates ListDirectory tool from ToolUse', function () { + const toolUse: ToolUse = { + toolUseId: 'test-id', + name: ToolType.ListDirectory, + input: { path: '/test/path' }, + } + + const result = ToolUtils.tryFromToolUse(toolUse) + + assert.strictEqual('type' in result, true) + if ('type' in result) { + assert.strictEqual(result.type, ToolType.ListDirectory) + assert(result.tool instanceof ListDirectory) + } + }) + + it('returns error result for unsupported tool', function () { + const toolUse: ToolUse = { + toolUseId: 'test-id', + name: 'UnsupportedTool' as any, + input: {}, + } + + const result = ToolUtils.tryFromToolUse(toolUse) + + assert.strictEqual('toolUseId' in result, true) + if ('toolUseId' in result) { + assert.strictEqual(result.toolUseId, 'test-id') + assert.strictEqual( + result.content?.[0].text ?? '', + 'The tool, "UnsupportedTool" is not supported by the client' + ) + } + }) + }) +}) + +describe('sanitizePath', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + it('trims whitespace from input path', function () { + const result = sanitizePath(' /test/path ') + assert.strictEqual(result, '/test/path') + }) + + it('expands tilde to user home directory', function () { + const homeDir = '/Users/testuser' + sandbox.stub(fs, 'getUserHomeDir').returns(homeDir) + + const result = sanitizePath('~/documents/file.txt') + assert.strictEqual(result, path.join(homeDir, 'documents/file.txt')) + }) + + it('converts relative paths to absolute paths', function () { + const result = sanitizePath('relative/path') + assert.strictEqual(result, path.resolve('relative/path')) + }) + + it('leaves absolute paths unchanged', function () { + const absolutePath = path.resolve('/absolute/path') + const result = sanitizePath(absolutePath) + assert.strictEqual(result, absolutePath) + }) +})