diff --git a/extensions/copilot/src/extension/tools/node/readFileTool.tsx b/extensions/copilot/src/extension/tools/node/readFileTool.tsx index 06fb90b9192..359d5995dcd 100644 --- a/extensions/copilot/src/extension/tools/node/readFileTool.tsx +++ b/extensions/copilot/src/extension/tools/node/readFileTool.tsx @@ -22,6 +22,7 @@ import { IWorkspaceService } from '../../../platform/workspace/common/workspaceS import { getCachedSha256Hash } from '../../../util/common/crypto'; import { clamp } from '../../../util/vs/base/common/numbers'; import { dirname, extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources'; +import { isHighSurrogate, isLowSurrogate } from '../../../util/vs/base/common/strings'; import { sendSkillContentReadTelemetry } from '../common/skillTelemetry'; import { URI } from '../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; @@ -432,7 +433,11 @@ class ReadFileResult extends PromptElement { let contents = rawContents.split('\n').map(line => { if (line.length > MAX_LINE_LENGTH) { hadLongLines = true; - return line.slice(0, MAX_LINE_LENGTH) + ' [truncated]'; + let end = MAX_LINE_LENGTH; + if (isHighSurrogate(line.charCodeAt(end - 1)) && isLowSurrogate(line.charCodeAt(end))) { + end--; + } + return line.slice(0, end) + ' [truncated]'; } return line; }).join('\n'); diff --git a/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx b/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx index 7e8206e2e47..548aeaf443b 100644 --- a/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx +++ b/extensions/copilot/src/extension/tools/node/test/readFile.spec.tsx @@ -42,13 +42,15 @@ suite('ReadFile', () => { const longLine = 'x'.repeat(2500); const longLinesContent = `normal line\n${longLine}\nanother normal line\n${longLine}`; const longLinesDoc = createTextDocumentData(URI.file('/workspace/longlines.ts'), longLinesContent, 'ts').document; + const surrogateBoundaryLine = 'x'.repeat(1999) + '\u{1F6E1}' + 'tail'; + const surrogateBoundaryDoc = createTextDocumentData(URI.file('/workspace/surrogate-boundary.ts'), surrogateBoundaryLine, 'ts').document; const services = createExtensionUnitTestingServices(); services.define(IWorkspaceService, new SyncDescriptor( TestWorkspaceService, [ [URI.file('/workspace')], - [testDoc, emptyDoc, whitespaceDoc, singleLineDoc, largeDoc, longLinesDoc], + [testDoc, emptyDoc, whitespaceDoc, singleLineDoc, largeDoc, longLinesDoc, surrogateBoundaryDoc], ] )); accessor = services.createTestingAccessor(); @@ -208,6 +210,18 @@ suite('ReadFile', () => { } }); + test('long line truncation does not split surrogate pairs', async () => { + const toolsService = accessor.get(IToolsService); + const input: IReadFileParamsV2 = { + filePath: '/workspace/surrogate-boundary.ts' + }; + const result = await toolsService.invokeTool(ToolName.ReadFile, { input, toolInvocationToken: null as never }, CancellationToken.None); + const resultString = await toolResultToString(accessor, result); + const truncatedLine = resultString.split('\n').find(line => line.endsWith(' [truncated]')); + + expect(truncatedLine).toBe('x'.repeat(1999) + ' [truncated]'); + }); + test('read file with offset beyond file line count should throw error', async () => { const toolsService = accessor.get(IToolsService);