diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e23a20d71d..30d482e2886 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,8 +42,11 @@ Then clone the repository and install NPM packages: ### Run -Due to the monorepo structure of the project, you must have the `aws-toolkit-vscode/packages/toolkit` folder open as root folder in the workspace. -The easiest way to open the project: File > Open Workspace from File > choose `aws-toolkit-vscode/aws-toolkit-vscode.code-workspace` +Due to the monorepo structure of the project, you must open the project using the +`aws-toolkit-vscode.code-workspace` project file. + +1. Run the `File: Open Workspace from File...` command in vscode. +2. Select the `aws-toolkit-vscode.code-workspace` project file. To run the extension from VSCode as a Node.js app: @@ -164,8 +167,7 @@ See [web.md](./docs/web.md) for working with the web mode implementation of the See [TESTPLAN.md](./docs/TESTPLAN.md) to understand the project's test structure, mechanics and philosophy. -You can run tests directly from VSCode. Due to the monorepo structure of the project, you must have the `aws-toolkit-vscode/packages/toolkit` folder open as root folder in the workspace. -The easiest way to open the project: File > Open Workspace from File > choose `aws-toolkit-vscode/aws-toolkit-vscode.code-workspace` +You can run tests directly from VSCode. Due to the monorepo structure of the project, you must [open the project via the `aws-toolkit-vscode.code-workspace` project file](#run). 1. Select `View > Debug`, or select the Debug pane from the sidebar. 2. From the dropdown at the top of the Debug pane, select the `Extension Tests` configuration. @@ -180,12 +182,13 @@ Tests will write logs to `./.test-reports/testLog.log`. #### Run a specific test -To run a single test in VSCode, do any one of: +To run a single test in VSCode, do any _one_ of the following: - Run the _Extension Tests (current file)_ launch-config. -- Use Mocha's [it.only()](https://mochajs.org/#exclusive-tests) or `describe.only()`. -- Run in your terminal: - + - Note: if you don't see this in the vscode debug menu, confirm that you opened the project + [via the `aws-toolkit-vscode.code-workspace` project file](#run). +- or... Use Mocha's [it.only()](https://mochajs.org/#exclusive-tests) or `describe.only()`. +- or... Run in your terminal: - Unix/macOS/POSIX shell: ``` TEST_FILE=../core/src/test/foo.test.ts npm run test @@ -194,8 +197,7 @@ To run a single test in VSCode, do any one of: ``` $Env:TEST_FILE = "../core/src/test/foo.test.ts"; npm run test ``` - -- To run all tests in a particular subdirectory, you can edit +- or... To run all tests in a particular subdirectory, you can edit `src/test/index.ts:rootTestsPath` to point to a subdirectory: ``` rootTestsPath: __dirname + '/shared/sam/debugger/' diff --git a/package-lock.json b/package-lock.json index fa850837f8f..04f05d4c886 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24995,8 +24995,9 @@ }, "node_modules/ts-node": { "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, - "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -26691,7 +26692,7 @@ }, "packages/amazonq": { "name": "amazon-q-vscode", - "version": "1.53.0-SNAPSHOT", + "version": "1.54.0-SNAPSHOT", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" @@ -27493,7 +27494,7 @@ }, "packages/toolkit": { "name": "aws-toolkit-vscode", - "version": "3.52.0-SNAPSHOT", + "version": "3.53.0-SNAPSHOT", "license": "Apache-2.0", "dependencies": { "aws-core-vscode": "file:../core/" diff --git a/packages/amazonq/.changes/1.53.0.json b/packages/amazonq/.changes/1.53.0.json new file mode 100644 index 00000000000..cb548513e35 --- /dev/null +++ b/packages/amazonq/.changes/1.53.0.json @@ -0,0 +1,22 @@ +{ + "date": "2025-03-28", + "version": "1.53.0", + "entries": [ + { + "type": "Bug Fix", + "description": "Amazon Q Chat: Choosing a nested subfolder for `/doc` on Windows results in `The folder you chose did not contain any source files` error" + }, + { + "type": "Feature", + "description": "Add support for Code search in Q chat" + }, + { + "type": "Feature", + "description": "(Experimental) Amazon Q inline code suggestions via Amazon Q Language Server. (enable with `aws.experiments.amazonqLSP: true`)" + }, + { + "type": "Feature", + "description": "Command Palette: Add `Amazon Q: Open Chat` command." + } + ] +} \ No newline at end of file diff --git a/packages/amazonq/.changes/next-release/Bug Fix-3f6a2026-9dd8-4b50-8a73-765ead8717b4.json b/packages/amazonq/.changes/next-release/Bug Fix-3f6a2026-9dd8-4b50-8a73-765ead8717b4.json deleted file mode 100644 index 81b888ea005..00000000000 --- a/packages/amazonq/.changes/next-release/Bug Fix-3f6a2026-9dd8-4b50-8a73-765ead8717b4.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "Amazon Q Chat: Choosing a nested subfolder for `/doc` on Windows results in `The folder you chose did not contain any source files` error" -} diff --git a/packages/amazonq/.changes/next-release/Feature-5f829ea1-e33b-4687-bc6d-76ed1558cd7e.json b/packages/amazonq/.changes/next-release/Feature-5f829ea1-e33b-4687-bc6d-76ed1558cd7e.json deleted file mode 100644 index 52370b566ce..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-5f829ea1-e33b-4687-bc6d-76ed1558cd7e.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Add support for Code search in Q chat" -} diff --git a/packages/amazonq/.changes/next-release/Feature-7a3c930e-cc8e-4323-9ecd-c1bf767fd2ed.json b/packages/amazonq/.changes/next-release/Feature-7a3c930e-cc8e-4323-9ecd-c1bf767fd2ed.json deleted file mode 100644 index 81a1e026db4..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-7a3c930e-cc8e-4323-9ecd-c1bf767fd2ed.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "(Experimental) Amazon Q inline code suggestions via Amazon Q Language Server. (enable with `aws.experiments.amazonqLSP: true`)" -} diff --git a/packages/amazonq/.changes/next-release/Feature-fa845e78-1e76-407a-be8a-e64c63b98795.json b/packages/amazonq/.changes/next-release/Feature-fa845e78-1e76-407a-be8a-e64c63b98795.json deleted file mode 100644 index b3d28917f4f..00000000000 --- a/packages/amazonq/.changes/next-release/Feature-fa845e78-1e76-407a-be8a-e64c63b98795.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Command Palette: Add `Amazon Q: Open Chat` command." -} diff --git a/packages/amazonq/CHANGELOG.md b/packages/amazonq/CHANGELOG.md index f938e36fbd6..c799cfbc69b 100644 --- a/packages/amazonq/CHANGELOG.md +++ b/packages/amazonq/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.53.0 2025-03-28 + +- **Bug Fix** Amazon Q Chat: Choosing a nested subfolder for `/doc` on Windows results in `The folder you chose did not contain any source files` error +- **Feature** Add support for Code search in Q chat +- **Feature** (Experimental) Amazon Q inline code suggestions via Amazon Q Language Server. (enable with `aws.experiments.amazonqLSP: true`) +- **Feature** Command Palette: Add `Amazon Q: Open Chat` command. + ## 1.52.0 2025-03-20 - **Bug Fix** Amazon Q chat: @Folders and @Files are missing `@` prefix in chat history diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index 8789002aba3..c9844eb6eb8 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -2,7 +2,7 @@ "name": "amazon-q-vscode", "displayName": "Amazon Q", "description": "The most capable generative AI-powered assistant for building, operating, and transforming software, with advanced capabilities for managing data and AI", - "version": "1.53.0-SNAPSHOT", + "version": "1.54.0-SNAPSHOT", "extensionKind": [ "workspace" ], diff --git a/packages/core/src/codewhisperer/client/user-service-2.json b/packages/core/src/codewhisperer/client/user-service-2.json index 986b4d465b0..93b857f6ac0 100644 --- a/packages/core/src/codewhisperer/client/user-service-2.json +++ b/packages/core/src/codewhisperer/client/user-service-2.json @@ -280,6 +280,22 @@ ], "documentation": "

Lists the findings from a particular code analysis job.

" }, + "ListEvents": { + "name": "ListEvents", + "http": { + "method": "POST", + "requestUri": "/" + }, + "input": { "shape": "ListEventsRequest" }, + "output": { "shape": "ListEventsResponse" }, + "errors": [ + { "shape": "ThrottlingException" }, + { "shape": "InternalServerException" }, + { "shape": "ValidationException" }, + { "shape": "AccessDeniedException" } + ], + "documentation": "

List events for agent activity

" + }, "ListFeatureEvaluations": { "name": "ListFeatureEvaluations", "http": { @@ -689,7 +705,9 @@ "acceptedLineCount": { "shape": "Integer" }, "acceptedSnippetHasReference": { "shape": "Boolean" }, "hasProjectLevelContext": { "shape": "Boolean" }, - "userIntent": { "shape": "UserIntent" } + "userIntent": { "shape": "UserIntent" }, + "addedIdeDiagnostics": { "shape": "IdeDiagnosticList" }, + "removedIdeDiagnostics": { "shape": "IdeDiagnosticList" } } }, "ChatInteractWithMessageEventInteractionTargetString": { @@ -1079,7 +1097,8 @@ "CreateWorkspaceRequestWorkspaceRootString": { "type": "string", "max": 1024, - "min": 1 + "min": 1, + "sensitive": true }, "CreateWorkspaceResponse": { "type": "structure", @@ -1394,6 +1413,10 @@ "useRelevantDocuments": { "shape": "Boolean", "documentation": "

Whether service should use relevant document in prompt

" + }, + "workspaceFolders": { + "shape": "WorkspaceFolderList", + "documentation": "

Represents IDE provided list of workspace folders

" } }, "documentation": "

Represents the state of an Editor

" @@ -1476,6 +1499,34 @@ "max": 2048, "min": 0 }, + "Event": { + "type": "structure", + "required": ["eventId", "generationId", "eventTimestamp", "eventType", "eventBlob"], + "members": { + "eventId": { "shape": "UUID" }, + "generationId": { "shape": "UUID" }, + "eventTimestamp": { "shape": "SyntheticTimestamp_date_time" }, + "eventType": { "shape": "EventType" }, + "eventBlob": { "shape": "EventBlob" } + } + }, + "EventBlob": { + "type": "blob", + "max": 400000, + "min": 1, + "sensitive": true + }, + "EventList": { + "type": "list", + "member": { "shape": "Event" }, + "max": 10, + "min": 1 + }, + "EventType": { + "type": "string", + "max": 100, + "min": 1 + }, "ExternalIdentityDetails": { "type": "structure", "members": { @@ -1629,7 +1680,8 @@ "TASK_ASSIST", "TRANSFORMATIONS", "CHAT_CUSTOMIZATION", - "TRANSFORMATIONS_WEBAPP" + "TRANSFORMATIONS_WEBAPP", + "FEATURE_DEVELOPMENT" ], "max": 64, "min": 1 @@ -1807,6 +1859,46 @@ "max": 64, "min": 1 }, + "IdeDiagnostic": { + "type": "structure", + "required": ["ideDiagnosticType"], + "members": { + "range": { + "shape": "Range", + "documentation": "

The range at which the message applies.

" + }, + "source": { + "shape": "IdeDiagnosticSourceString", + "documentation": "

A human-readable string describing the source of the diagnostic

" + }, + "severity": { + "shape": "DiagnosticSeverity", + "documentation": "

Diagnostic Error type

" + }, + "ideDiagnosticType": { + "shape": "IdeDiagnosticType", + "documentation": "

Type of the diagnostic

" + } + }, + "documentation": "

Structure to represent metadata about a Diagnostic from user local IDE

" + }, + "IdeDiagnosticList": { + "type": "list", + "member": { "shape": "IdeDiagnostic" }, + "documentation": "

List of IDE Diagnostics

", + "max": 1024, + "min": 0 + }, + "IdeDiagnosticSourceString": { + "type": "string", + "max": 1024, + "min": 0, + "sensitive": true + }, + "IdeDiagnosticType": { + "type": "string", + "enum": ["SYNTAX_ERROR", "TYPE_ERROR", "REFERENCE_ERROR", "BEST_PRACTICE", "SECURITY", "OTHER"] + }, "IdempotencyToken": { "type": "string", "max": 256, @@ -1995,6 +2087,30 @@ "codeAnalysisFindings": { "shape": "SensitiveString" } } }, + "ListEventsRequest": { + "type": "structure", + "required": ["conversationId"], + "members": { + "conversationId": { "shape": "UUID" }, + "maxResults": { "shape": "ListEventsRequestMaxResultsInteger" }, + "nextToken": { "shape": "NextToken" } + } + }, + "ListEventsRequestMaxResultsInteger": { + "type": "integer", + "box": true, + "max": 50, + "min": 1 + }, + "ListEventsResponse": { + "type": "structure", + "required": ["conversationId", "events"], + "members": { + "conversationId": { "shape": "UUID" }, + "events": { "shape": "EventList" }, + "nextToken": { "shape": "NextToken" } + } + }, "ListFeatureEvaluationsRequest": { "type": "structure", "required": ["userContext"], @@ -2023,7 +2139,8 @@ "ListWorkspaceMetadataRequestWorkspaceRootString": { "type": "string", "max": 1024, - "min": 1 + "min": 1, + "sensitive": true }, "ListWorkspaceMetadataResponse": { "type": "structure", @@ -2066,6 +2183,11 @@ "min": 1, "pattern": "[-a-zA-Z0-9._]*" }, + "NextToken": { + "type": "string", + "max": 1000, + "min": 0 + }, "Notifications": { "type": "list", "member": { "shape": "NotificationsFeature" }, @@ -2893,6 +3015,10 @@ "type": "string", "enum": ["DECLARATION", "USAGE"] }, + "SyntheticTimestamp_date_time": { + "type": "timestamp", + "timestampFormat": "iso8601" + }, "TargetCode": { "type": "structure", "required": ["relativeTargetPath"], @@ -3752,7 +3878,9 @@ "generatedLine": { "shape": "PrimitiveInteger" }, "numberOfRecommendations": { "shape": "PrimitiveInteger" }, "perceivedLatencyMilliseconds": { "shape": "Double" }, - "acceptedCharacterCount": { "shape": "PrimitiveInteger" } + "acceptedCharacterCount": { "shape": "PrimitiveInteger" }, + "addedIdeDiagnostics": { "shape": "IdeDiagnosticList" }, + "removedIdeDiagnostics": { "shape": "IdeDiagnosticList" } } }, "ValidationException": { @@ -3786,6 +3914,18 @@ "programmingLanguage": { "shape": "ProgrammingLanguage" } } }, + "WorkspaceFolderList": { + "type": "list", + "member": { "shape": "WorkspaceFolderListMemberString" }, + "max": 100, + "min": 0 + }, + "WorkspaceFolderListMemberString": { + "type": "string", + "max": 4096, + "min": 1, + "sensitive": true + }, "WorkspaceList": { "type": "list", "member": { "shape": "WorkspaceMetadata" } diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index ec1e383bde5..ee21f252ffb 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -305,7 +305,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) { diff --git a/packages/core/src/codewhispererChat/tools/fsRead.ts b/packages/core/src/codewhispererChat/tools/fsRead.ts index a078347dc3d..7ff57935aad 100644 --- a/packages/core/src/codewhispererChat/tools/fsRead.ts +++ b/packages/core/src/codewhispererChat/tools/fsRead.ts @@ -4,7 +4,6 @@ */ import * as vscode from 'vscode' import { getLogger } from '../../shared/logger/logger' -import { readDirectoryRecursively } from '../../shared/utilities/workspaceUtils' import fs from '../../shared/fs/fs' import { InvokeOutput, maxToolResponseSize, OutputKind, sanitizePath } from './toolShared' import { Writable } from 'stream' @@ -18,7 +17,6 @@ export interface FsReadParams { export class FsRead { private fsPath: string private readonly readRange?: number[] - private isFile?: boolean // true for file, false for directory private readonly logger = getLogger('fsRead') constructor(params: FsReadParams) { @@ -36,24 +34,37 @@ export class FsRead { this.fsPath = sanitized const fileUri = vscode.Uri.file(this.fsPath) - let exists: boolean + let fileExists: boolean try { - exists = await fs.exists(fileUri) - if (!exists) { + 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.isFile = await fs.existsFile(fileUri) this.logger.debug(`Validation succeeded for path: ${this.fsPath}`) } public queueDescription(updates: Writable): void { const fileName = path.basename(this.fsPath) const fileUri = vscode.Uri.file(this.fsPath) - updates.write(`Reading: [${fileName}](${fileUri})`) + updates.write(`Reading file: [${fileName}](${fileUri}), `) + + const [start, end] = this.readRange ?? [] + + if (start && end) { + updates.write(`from line ${start} to ${end}`) + } else if (start) { + if (start > 0) { + updates.write(`from line ${start} to end of file`) + } else { + updates.write(`${start} line from the end of file to end of file`) + } + } else { + updates.write('all lines') + } updates.end() } @@ -61,20 +72,12 @@ export class FsRead { try { const fileUri = vscode.Uri.file(this.fsPath) - if (this.isFile) { - const fileContents = await this.readFile(fileUri) - this.logger.info(`Read file: ${this.fsPath}, size: ${fileContents.length}`) - return this.handleFileRange(fileContents) - } else if (!this.isFile) { - const maxDepth = this.getDirectoryDepth() ?? 0 - const listing = await readDirectoryRecursively(fileUri, maxDepth) - return this.createOutput(listing.join('\n')) - } else { - throw new Error(`"${this.fsPath}" is neither a standard file nor directory.`) - } + 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(`[fs_read] Failed to read "${this.fsPath}": ${error.message || error}`) + throw new Error(`Failed to read "${this.fsPath}": ${error.message || error}`) } } @@ -118,13 +121,6 @@ export class FsRead { return [finalStart, finalEnd] } - private getDirectoryDepth(): number | undefined { - if (!this.readRange || this.readRange.length === 0) { - return 0 - } - return this.readRange[0] - } - private enforceMaxSize(content: string): string { const byteCount = Buffer.byteLength(content, 'utf8') if (byteCount > maxToolResponseSize) { diff --git a/packages/core/src/codewhispererChat/tools/listDirectory.ts b/packages/core/src/codewhispererChat/tools/listDirectory.ts new file mode 100644 index 00000000000..1d03d22303a --- /dev/null +++ b/packages/core/src/codewhispererChat/tools/listDirectory.ts @@ -0,0 +1,70 @@ +/*! + * 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 { InvokeOutput, OutputKind, sanitizePath } from './toolShared' +import { Writable } from 'stream' +import path from 'path' + +export interface ListDirectoryParams { + path: string +} + +export class ListDirectory { + private fsPath: string + private readonly logger = getLogger('listDirectory') + + constructor(params: ListDirectoryParams) { + this.fsPath = params.path + } + + public async validate(): Promise { + if (!this.fsPath || this.fsPath.trim().length === 0) { + throw new Error('Path cannot be empty.') + } + + 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) + updates.write(`Listing directory on filePath: ${fileName}`) + updates.end() + } + + public async invoke(updates: Writable): Promise { + try { + const fileUri = vscode.Uri.file(this.fsPath) + const listing = await readDirectoryRecursively(fileUri, 0) + 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/toolUtils.ts b/packages/core/src/codewhispererChat/tools/toolUtils.ts index 20bfa1afb0e..acda3e19022 100644 --- a/packages/core/src/codewhispererChat/tools/toolUtils.ts +++ b/packages/core/src/codewhispererChat/tools/toolUtils.ts @@ -8,17 +8,20 @@ import { FsWrite, FsWriteParams } from './fsWrite' import { ExecuteBash, ExecuteBashParams } from './executeBash' import { ToolResult, ToolResultContentBlock, ToolResultStatus, ToolUse } from '@amzn/codewhisperer-streaming' import { InvokeOutput } from './toolShared' +import { ListDirectory, ListDirectoryParams } from './listDirectory' export enum ToolType { FsRead = 'fsRead', FsWrite = 'fsWrite', ExecuteBash = 'executeBash', + ListDirectory = 'listDirectory', } export type Tool = | { type: ToolType.FsRead; tool: FsRead } | { type: ToolType.FsWrite; tool: FsWrite } | { type: ToolType.ExecuteBash; tool: ExecuteBash } + | { type: ToolType.ListDirectory; tool: ListDirectory } export class ToolUtils { static displayName(tool: Tool): string { @@ -29,6 +32,8 @@ export class ToolUtils { return 'Write to filesystem' case ToolType.ExecuteBash: return 'Execute shell command' + case ToolType.ListDirectory: + return 'List directory from filesystem' } } @@ -40,6 +45,8 @@ export class ToolUtils { return true case ToolType.ExecuteBash: return tool.tool.requiresAcceptance() + case ToolType.ListDirectory: + return false } } @@ -51,6 +58,8 @@ export class ToolUtils { return tool.tool.invoke(updates) case ToolType.ExecuteBash: return tool.tool.invoke(updates) + case ToolType.ListDirectory: + return tool.tool.invoke(updates) } } @@ -65,6 +74,9 @@ export class ToolUtils { case ToolType.ExecuteBash: tool.tool.queueDescription(updates) break + case ToolType.ListDirectory: + tool.tool.queueDescription(updates) + break } } @@ -76,6 +88,8 @@ export class ToolUtils { return tool.tool.validate() case ToolType.ExecuteBash: return tool.tool.validate() + case ToolType.ListDirectory: + return tool.tool.validate() } } @@ -108,6 +122,11 @@ export class ToolUtils { 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), + } default: return { toolUseId: value.toolUseId, diff --git a/packages/core/src/codewhispererChat/tools/tool_index.json b/packages/core/src/codewhispererChat/tools/tool_index.json index 58b8731e264..7319e84253e 100644 --- a/packages/core/src/codewhispererChat/tools/tool_index.json +++ b/packages/core/src/codewhispererChat/tools/tool_index.json @@ -1,16 +1,16 @@ { "fsRead": { "name": "fsRead", - "description": "A tool for reading files (e.g. `cat -n`), or listing files/directories (e.g. `ls -la` or `find . -maxdepth 2). The behavior of this tool is determined by the `path` parameter pointing to a file or directory.\n* If `path` is a file, this tool returns the result of running `cat -n`, and the optional `readRange` determines what range of lines will be read from the specified file.\n* If `path` is a directory, this tool returns the listed files and directories of the specified path, as if running `ls -la`. If the `readRange` parameter is provided, the tool acts like the `find . -maxdepth `, where `readRange` is the number of subdirectories deep to search, e.g. [2] will run `find . -maxdepth 2`.", + "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 file or directory, e.g. `/repo/file.py` or `/repo`.", + "description": "Absolute path to a file, e.g. `/repo/file.py`.", "type": "string" }, "readRange": { - "description": "Optional parameter when reading either files or directories.\n* When `path` is a file, 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.\n* When `path` is a directory, if none is given, the results of `ls -l` are given. If provided, the current directory and indicated number of subdirectories will be shown, e.g. [2] will show the current directory and directories two levels deep.", + "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.", "items": { "type": "integer" }, @@ -22,7 +22,7 @@ }, "fsWrite": { "name": "fsWrite", - "description": "A tool for creating and editing files\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.", + "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": { @@ -72,5 +72,23 @@ }, "required": ["command", "cwd"] } + }, + "listDirectory": { + "name": "listDirectory", + "description": "List the contents of a directory.\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`." + } + }, + "required": ["path"] + } } } diff --git a/packages/core/src/shared/clients/codewhispererChatClient.ts b/packages/core/src/shared/clients/codewhispererChatClient.ts index 192a3f13e9f..93b7b3bceb4 100644 --- a/packages/core/src/shared/clients/codewhispererChatClient.ts +++ b/packages/core/src/shared/clients/codewhispererChatClient.ts @@ -17,9 +17,7 @@ export async function createCodeWhispererChatStreamingClient(): Promise 500 + attempt ** 10), + retryStrategy: new ConfiguredRetryStrategy(1, (attempt: number) => 500 + attempt ** 10), }) return streamingClient } diff --git a/packages/core/src/shared/logger/logger.ts b/packages/core/src/shared/logger/logger.ts index 32680cb57b6..4f6c759b0ec 100644 --- a/packages/core/src/shared/logger/logger.ts +++ b/packages/core/src/shared/logger/logger.ts @@ -19,6 +19,7 @@ export type LogTopic = | 'fsRead' | 'fsWrite' | 'executeBash' + | 'listDirectory' | 'chatStream' | 'unknown' diff --git a/packages/core/src/shared/utilities/workspaceUtils.ts b/packages/core/src/shared/utilities/workspaceUtils.ts index e97297ad265..61747fa1a1d 100644 --- a/packages/core/src/shared/utilities/workspaceUtils.ts +++ b/packages/core/src/shared/utilities/workspaceUtils.ts @@ -673,14 +673,14 @@ export async function findStringInDirectory(searchStr: string, dirPath: string) } /** - * Returns a one-character tag for a directory ('d'), symlink ('l'), or file ('-'). + * 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 = '-' + let typeChar = '[FILE]' if (fileType === vscode.FileType.Directory) { - typeChar = 'd' + typeChar = '[DIR]' } else if (fileType === vscode.FileType.SymbolicLink) { - typeChar = 'l' + typeChar = '[LINK]' } return `${typeChar} ${fullPath}` } diff --git a/packages/core/src/test/codewhispererChat/tools/fsRead.test.ts b/packages/core/src/test/codewhispererChat/tools/fsRead.test.ts index befe5ca624b..d7bf76456b1 100644 --- a/packages/core/src/test/codewhispererChat/tools/fsRead.test.ts +++ b/packages/core/src/test/codewhispererChat/tools/fsRead.test.ts @@ -43,23 +43,6 @@ describe('FsRead Tool', () => { assert.strictEqual(result.output.content, 'B\nC\nD') }) - it('lists directory contents up to depth = 1', async () => { - await testFolder.mkdir('subfolder') - await testFolder.write('fileA.txt', 'fileA content') - await testFolder.write(path.join('subfolder', 'fileB.md'), '# fileB') - - const fsRead = new FsRead({ path: testFolder.path, readRange: [1] }) - await fsRead.validate() - const result = await fsRead.invoke(process.stdout) - - const lines = result.output.content.split('\n') - const hasFileA = lines.some((line: string | string[]) => line.includes('- ') && line.includes('fileA.txt')) - const hasSubfolder = lines.some((line: string | string[]) => line.includes('d ') && 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('throws error if path does not exist', async () => { const missingPath = path.join(testFolder.path, 'no_such_file.txt') const fsRead = new FsRead({ path: missingPath }) @@ -94,29 +77,4 @@ describe('FsRead Tool', () => { assert.strictEqual(result.output.kind, 'text') assert.strictEqual(result.output.content, '') }) - - it('expands ~ path', async () => { - const fsRead = new FsRead({ path: '~' }) - await fsRead.validate() - const result = await fsRead.invoke(process.stdout) - - assert.strictEqual(result.output.kind, 'text') - assert.ok(result.output.content.length > 0) - }) - - it('resolves relative path', async () => { - await testFolder.mkdir('relTest') - const filePath = path.join('relTest', 'relFile.txt') - const content = 'Hello from a relative file!' - await testFolder.write(filePath, content) - - const relativePath = path.relative(process.cwd(), path.join(testFolder.path, filePath)) - - const fsRead = new FsRead({ path: relativePath }) - await fsRead.validate() - const result = await fsRead.invoke(process.stdout) - - assert.strictEqual(result.output.kind, 'text') - assert.strictEqual(result.output.content, content) - }) }) 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..10ed6d202bc --- /dev/null +++ b/packages/core/src/test/codewhispererChat/tools/listDirectory.test.ts @@ -0,0 +1,60 @@ +/*! + * 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' + +describe('ListDirectory Tool', () => { + let testFolder: TestFolder + + before(async () => { + testFolder = await TestFolder.create() + }) + + it('throws if path is empty', async () => { + const listDirectory = new ListDirectory({ path: '' }) + await assert.rejects(listDirectory.validate(), /Path cannot be empty/i, 'Expected an error about empty path') + }) + + it('lists directory contents', 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') + ) + + assert.ok(hasFileA, 'Should list fileA.txt in the directory output') + assert.ok(hasSubfolder, 'Should list 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 }) + + 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: '~' }) + await listDirectory.validate() + const result = await listDirectory.invoke(process.stdout) + + assert.strictEqual(result.output.kind, 'text') + assert.ok(result.output.content.length > 0) + }) +}) diff --git a/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts b/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts index 4395258cbdf..c7ba3363b59 100644 --- a/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts +++ b/packages/core/src/test/codewhispererChat/tools/toolShared.test.ts @@ -14,12 +14,14 @@ 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 () { @@ -27,6 +29,7 @@ describe('ToolUtils', function () { 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 @@ -51,6 +54,11 @@ describe('ToolUtils', 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 () { @@ -74,6 +82,11 @@ describe('ToolUtils', function () { 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), false) + }) }) describe('invoke', function () { @@ -124,6 +137,22 @@ describe('ToolUtils', function () { assert.deepStrictEqual(result, expectedOutput) assert(mockExecuteBash.invoke.calledOnceWith(mockWritable)) }) + + 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)) + }) }) describe('queueDescription', function () { @@ -147,6 +176,13 @@ describe('ToolUtils', function () { 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 } + ToolUtils.queueDescription(tool, mockWritable as unknown as Writable) + + assert(mockListDirectory.queueDescription.calledOnceWith(mockWritable)) + }) }) describe('validate', function () { @@ -176,6 +212,15 @@ describe('ToolUtils', function () { 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 () { @@ -227,6 +272,22 @@ describe('ToolUtils', function () { } }) + 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', diff --git a/packages/toolkit/.changes/3.52.0.json b/packages/toolkit/.changes/3.52.0.json new file mode 100644 index 00000000000..7fb11ef522f --- /dev/null +++ b/packages/toolkit/.changes/3.52.0.json @@ -0,0 +1,14 @@ +{ + "date": "2025-03-28", + "version": "3.52.0", + "entries": [ + { + "type": "Bug Fix", + "description": "SAM build: prevent running multiple build processes for the same template" + }, + { + "type": "Feature", + "description": "Lambda: Add 'Process SNS notification messages with Lambda' Serverless Land pattern." + } + ] +} \ No newline at end of file diff --git a/packages/toolkit/.changes/next-release/Bug Fix-db0d3605-5cb7-4b78-b4ae-29be86f51f99.json b/packages/toolkit/.changes/next-release/Bug Fix-db0d3605-5cb7-4b78-b4ae-29be86f51f99.json deleted file mode 100644 index 2124088e427..00000000000 --- a/packages/toolkit/.changes/next-release/Bug Fix-db0d3605-5cb7-4b78-b4ae-29be86f51f99.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Bug Fix", - "description": "SAM build: prevent running multiple build processes for the same template" -} diff --git a/packages/toolkit/.changes/next-release/Feature-279a0842-feff-4089-8529-61376bf8cf97.json b/packages/toolkit/.changes/next-release/Feature-279a0842-feff-4089-8529-61376bf8cf97.json deleted file mode 100644 index 820d634b943..00000000000 --- a/packages/toolkit/.changes/next-release/Feature-279a0842-feff-4089-8529-61376bf8cf97.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "Feature", - "description": "Lambda: Add 'Process SNS notification messages with Lambda' Serverless Land pattern." -} diff --git a/packages/toolkit/CHANGELOG.md b/packages/toolkit/CHANGELOG.md index 1793f0df4bb..34a9087d5c4 100644 --- a/packages/toolkit/CHANGELOG.md +++ b/packages/toolkit/CHANGELOG.md @@ -1,3 +1,8 @@ +## 3.52.0 2025-03-28 + +- **Bug Fix** SAM build: prevent running multiple build processes for the same template +- **Feature** Lambda: Add 'Process SNS notification messages with Lambda' Serverless Land pattern. + ## 3.51.0 2025-03-20 - **Feature** Update Step Functions marketplace documentation. diff --git a/packages/toolkit/package.json b/packages/toolkit/package.json index 1be6c628ae6..b21c6ef0204 100644 --- a/packages/toolkit/package.json +++ b/packages/toolkit/package.json @@ -2,7 +2,7 @@ "name": "aws-toolkit-vscode", "displayName": "AWS Toolkit", "description": "Including CodeCatalyst, Infrastructure Composer, and support for Lambda, S3, CloudWatch Logs, CloudFormation, and many other services.", - "version": "3.52.0-SNAPSHOT", + "version": "3.53.0-SNAPSHOT", "extensionKind": [ "workspace" ],