diff --git a/extensions/copilot/src/extension/tools/node/getErrorsTool.tsx b/extensions/copilot/src/extension/tools/node/getErrorsTool.tsx index ab4f8bac1b2ab..55dfc9e7e71c3 100644 --- a/extensions/copilot/src/extension/tools/node/getErrorsTool.tsx +++ b/extensions/copilot/src/extension/tools/node/getErrorsTool.tsx @@ -18,7 +18,8 @@ import { coalesce } from '../../../util/vs/base/common/arrays'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { ResourceSet } from '../../../util/vs/base/common/map'; -import { isEqualOrParent } from '../../../util/vs/base/common/resources'; +import { isWindows } from '../../../util/vs/base/common/platform'; +import { isEqual, isEqualOrParent } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { DiagnosticSeverity, ExtendedLanguageModelToolResult, LanguageModelPromptTsxPart, MarkdownString, Range } from '../../../vscodeTypes'; @@ -95,13 +96,15 @@ export class GetErrorsTool extends Disposable implements ICopilotTool= 3 && path[0] === '/' && path[2] === ':') { + const lowerDrive = path[1].toLowerCase(); + if (lowerDrive !== path[1]) { + return uri.with({ path: `/${lowerDrive}${path.slice(2)}` }); + } + } + return uri; + } + async invoke(options: vscode.LanguageModelToolInvocationOptions, token: CancellationToken) { const getAll = () => this.languageDiagnosticsService.getAllDiagnostics() .map(d => ({ uri: d[0], diagnostics: d[1].filter(e => e.severity <= DiagnosticSeverity.Warning), inputUri: undefined })) @@ -356,4 +401,4 @@ export class DiagnosticToolOutput extends PromptElement; } -} \ No newline at end of file +} diff --git a/extensions/copilot/src/extension/tools/node/test/getErrorsTool.spec.tsx b/extensions/copilot/src/extension/tools/node/test/getErrorsTool.spec.tsx index 96630579f59b6..a5846ffac16c6 100644 --- a/extensions/copilot/src/extension/tools/node/test/getErrorsTool.spec.tsx +++ b/extensions/copilot/src/extension/tools/node/test/getErrorsTool.spec.tsx @@ -14,6 +14,7 @@ import { TestWorkspaceService } from '../../../../platform/test/node/testWorkspa import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService'; import { createTextDocumentData } from '../../../../util/common/test/shims/textDocument'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; +import { isWindows } from '../../../../util/vs/base/common/platform'; import { URI } from '../../../../util/vs/base/common/uri'; import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; @@ -38,6 +39,8 @@ suite('GetErrorsTool - Tool Invocation', () => { const noErrorFile = URI.file('/test/workspace/src/noErrorFile.ts'); const eslintErrorFile = URI.file('/test/workspace/eslint/eslint_unexpected_constant_condition_1.ts'); const emptyLineErrorFile = URI.file('/test/workspace/emptyLineError.ts'); + const upperCaseDriveFile = URI.file('C:/test/workspace/src/windowsDriveFile.ts'); + const lowerCaseDriveFile = URI.file('c:/test/workspace/src/windowsDriveFile.ts'); beforeEach(() => { collection = createExtensionUnitTestingServices(); @@ -50,8 +53,9 @@ suite('GetErrorsTool - Tool Invocation', () => { const eslintErrorDoc = createTextDocumentData(eslintErrorFile, 'if (true) {\n console.log("This is a constant condition");\n}', 'ts').document; // File with a trailing empty line where the error is reported const emptyLineErrorDoc = createTextDocumentData(emptyLineErrorFile, 'codeunit 50100 MyCU {\n procedure Foo() {\n\n', 'ts').document; + const upperCaseDriveDoc = createTextDocumentData(upperCaseDriveFile, 'const drive = "C";\n', 'ts').document; - collection.define(IWorkspaceService, new SyncDescriptor(TestWorkspaceService, [[workspaceFolder], [tsDoc1, tsDoc2, jsDoc, noErrorDoc, eslintErrorDoc, emptyLineErrorDoc]])); + collection.define(IWorkspaceService, new SyncDescriptor(TestWorkspaceService, [[workspaceFolder], [tsDoc1, tsDoc2, jsDoc, noErrorDoc, eslintErrorDoc, emptyLineErrorDoc, upperCaseDriveDoc]])); // Set up diagnostics service diagnosticsService = new TestLanguageDiagnosticsService(); @@ -138,6 +142,21 @@ suite('GetErrorsTool - Tool Invocation', () => { ]); }); + test.skipIf(!isWindows)('getDiagnostics - matches Windows file path with different drive letter casing', () => { + const diagnostic = { + message: 'Drive letter casing diagnostic', + range: new Range(0, 6, 0, 11), + severity: DiagnosticSeverity.Error + }; + diagnosticsService.setDiagnostics(upperCaseDriveFile, [diagnostic]); + + const results = tool.getDiagnostics([{ uri: lowerCaseDriveFile, range: undefined }]); + + expect(results).toEqual([ + { uri: upperCaseDriveFile, diagnostics: [diagnostic] } + ]); + }); + test('getDiagnostics - filters by folder path', () => { // Test with folder path const srcFolder = URI.file('/test/workspace/src'); @@ -211,6 +230,22 @@ suite('GetErrorsTool - Tool Invocation', () => { expect(msg).toMatchSnapshot(); }); + test.skipIf(!isWindows)('Tool invocation - filePath matches diagnostics when Windows drive letter casing differs', async () => { + diagnosticsService.setDiagnostics(upperCaseDriveFile, [ + { + message: 'Drive letter casing diagnostic', + range: new Range(0, 6, 0, 11), + severity: DiagnosticSeverity.Error + } + ]); + + const pathRep = accessor.get(IPromptPathRepresentationService); + const result = await tool.invoke({ input: { filePaths: [lowerCaseDriveFile.fsPath] }, toolInvocationToken: null! }, CancellationToken.None); + const msg = await toolResultToString(accessor, result); + expect(msg).toContain(``); + expect(msg).toContain('Drive letter casing diagnostic'); + }); + test('Tool invocation - with folder path includes diagnostics from contained files', async () => { const pathRep = accessor.get(IPromptPathRepresentationService); const srcFolderUri = URI.file('/test/workspace/src'); @@ -260,4 +295,82 @@ suite('GetErrorsTool - Tool Invocation', () => { const msg = await toolResultToString(accessor, result); expect(msg).toMatchSnapshot(); }); -}); \ No newline at end of file +}); + +suite('GetErrorsTool - normalizeDriveLetter', () => { + let accessor: ITestingServicesAccessor; + let collection: TestingServiceCollection; + let tool: GetErrorsTool; + + beforeEach(() => { + collection = createExtensionUnitTestingServices(); + const workspaceFolder = URI.file('/test/workspace'); + const tsDoc = createTextDocumentData(URI.file('/test/workspace/file.ts'), '', 'ts').document; + collection.define(IWorkspaceService, new SyncDescriptor(TestWorkspaceService, [[workspaceFolder], [tsDoc]])); + collection.define(ILanguageDiagnosticsService, new TestLanguageDiagnosticsService()); + const fileSystemService = new MockFileSystemService(); + collection.define(IFileSystemService, fileSystemService); + accessor = collection.createTestingAccessor(); + tool = accessor.get(IInstantiationService).createInstance(GetErrorsTool); + }); + + afterEach(() => { + accessor.dispose(); + }); + + test.skipIf(isWindows)('non-Windows: returns the same URI instance for any path shape', () => { + const uris = [ + URI.parse('file:///C:/foo/bar.ts'), + URI.parse('file:///c:/foo/bar.ts'), + URI.parse('file:///foo/bar.ts'), + URI.parse('file:///'), + ]; + for (const uri of uris) { + expect(tool.normalizeDriveLetter(uri)).toBe(uri); + } + }); + + test.skipIf(!isWindows)('Windows: lowercases an uppercase drive letter', () => { + const uri = URI.parse('file:///C:/foo/bar.ts'); + const result = tool.normalizeDriveLetter(uri); + expect(result.path).toBe('/c:/foo/bar.ts'); + }); + + test.skipIf(!isWindows)('Windows: returns same URI instance when drive letter is already lowercase', () => { + const uri = URI.parse('file:///c:/foo/bar.ts'); + expect(tool.normalizeDriveLetter(uri)).toBe(uri); + }); + + test.skipIf(!isWindows)('Windows: preserves case of path components after the drive letter', () => { + const uri = URI.parse('file:///C:/MyProject/SrcFile.ts'); + const result = tool.normalizeDriveLetter(uri); + expect(result.path).toBe('/c:/MyProject/SrcFile.ts'); + }); + + test.skipIf(!isWindows)('Windows: returns URI unchanged when path has no drive letter', () => { + const uri = URI.parse('file:///foo/bar.ts'); + expect(tool.normalizeDriveLetter(uri)).toBe(uri); + }); + + test.skipIf(!isWindows)('Windows: returns URI unchanged when path is too short to contain a drive letter', () => { + const uri = URI.parse('file:///c'); + expect(tool.normalizeDriveLetter(uri)).toBe(uri); + }); + + test.skipIf(!isWindows)('Windows: returns URI unchanged for root path', () => { + const uri = URI.parse('file:///'); + expect(tool.normalizeDriveLetter(uri)).toBe(uri); + }); + + test.skipIf(!isWindows)('Windows: normalizes drive letter when path ends right after the colon', () => { + const uri = URI.parse('file:///C:'); + const result = tool.normalizeDriveLetter(uri); + expect(result.path).toBe('/c:'); + }); + + test.skipIf(!isWindows)('Windows: returns URI unchanged when drive position holds a non-letter character', () => { + // digit at drive position — `toLowerCase()` is a no-op so the URI is returned as-is + const uri = URI.parse('file:///1:/foo/bar.ts'); + expect(tool.normalizeDriveLetter(uri)).toBe(uri); + }); +});