diff --git a/build/buildfile.ts b/build/buildfile.ts index c2dbccb44dde5..7aa12a0ef3022 100644 --- a/build/buildfile.ts +++ b/build/buildfile.ts @@ -19,6 +19,7 @@ export const workerLocalFileSearch = createModuleDescription('vs/workbench/servi export const workerProfileAnalysis = createModuleDescription('vs/platform/profiling/electron-browser/profileAnalysisWorkerMain'); export const workerOutputLinks = createModuleDescription('vs/workbench/contrib/output/common/outputLinkComputerMain'); export const workerBackgroundTokenization = createModuleDescription('vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.workerMain'); +export const consoleCompactor = createModuleDescription('vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/consoleCompactor/consoleCompactor'); export const workbenchDesktop = [ createModuleDescription('vs/workbench/contrib/debug/node/telemetryApp'), @@ -29,7 +30,8 @@ export const workbenchDesktop = [ createModuleDescription('vs/platform/agentHost/node/diffWorkerMain'), createModuleDescription('vs/workbench/api/node/extensionHostProcess'), createModuleDescription('vs/workbench/workbench.desktop.main'), - createModuleDescription('vs/sessions/sessions.desktop.main') + createModuleDescription('vs/sessions/sessions.desktop.main'), + consoleCompactor, ]; export const workbenchWeb = createModuleDescription('vs/workbench/workbench.web.main.internal'); @@ -74,6 +76,7 @@ const buildfile = { workerProfileAnalysis, workerOutputLinks, workerBackgroundTokenization, + consoleCompactor, workbenchDesktop, workbenchWeb, sessionsWeb, diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts index f0968a51bdc26..c6179522b3267 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts @@ -122,6 +122,7 @@ const webEntryPoints = [ buildfile.workerOutputLinks, buildfile.workerBackgroundTokenization, buildfile.keyboardMaps, + buildfile.consoleCompactor, buildfile.codeWeb ].flat(); diff --git a/build/gulpfile.vscode.web.ts b/build/gulpfile.vscode.web.ts index 7ff1d58c1c541..1af613aa027b7 100644 --- a/build/gulpfile.vscode.web.ts +++ b/build/gulpfile.vscode.web.ts @@ -116,6 +116,7 @@ const vscodeWebEntryPoints = [ buildfile.workerOutputLinks, buildfile.workerBackgroundTokenization, buildfile.keyboardMaps, + buildfile.consoleCompactor, buildfile.workbenchWeb, buildfile.sessionsWeb, ].flat(); diff --git a/build/next/index.ts b/build/next/index.ts index 60597b7b05106..7075a37ca3a99 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -21,6 +21,7 @@ import { isWebExtension, type IScannedBuiltinExtension } from '../lib/extensions import { runBuildFast } from './build-fast.ts'; import { bundleDevTunnelsWeb } from './devTunnelsWeb.ts'; import { copyFile, mapWithConcurrency, MAX_CONCURRENT_FILE_OPERATIONS, transpileFile } from './transpile.ts'; +import { consoleCompactor } from '../buildfile.ts'; const globAsync = promisify(glob); @@ -166,6 +167,7 @@ function getEntryPointsForTarget(target: BuildTarget): string[] { ...desktopWorkerEntryPoints, ...desktopEntryPoints, ...codeEntryPoints, + consoleCompactor.name, ]; case 'server': return [ @@ -177,6 +179,7 @@ function getEntryPointsForTarget(target: BuildTarget): string[] { ...workerEntryPoints, ...webEntryPoints, ...keyboardMapEntryPoints, + consoleCompactor.name, ]; case 'web': return [ @@ -184,6 +187,7 @@ function getEntryPointsForTarget(target: BuildTarget): string[] { ...webOnlyEntryPoints, 'vs/workbench/workbench.web.main.internal', // web workbench only (no browser shell) ...keyboardMapEntryPoints, + consoleCompactor.name, ]; default: throw new Error(`Unknown target: ${target}`); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/consoleCompactor/consoleCompactor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/consoleCompactor/consoleCompactor.ts index 65377561b48ca..0d7f01d4e94d7 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/consoleCompactor/consoleCompactor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/consoleCompactor/consoleCompactor.ts @@ -6,6 +6,7 @@ // Shell-output compactor. Classifies shell commands and strips non-diagnostic // noise from their output. This is a faithful TypeScript port of the original // Rust implementation (lib.rs, report.rs, shell_output_compactor.rs). +// Bundled separately and loaded on demand; keep this module independent of workbench services. //#region Public API types diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 405cc2e35be44..f846d6a67fb5a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -13,6 +13,7 @@ import { appendEscapedMarkdownInlineCode, escapeMarkdownSyntaxTokens, MarkdownSt import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { getMediaMime } from '../../../../../../base/common/mime.js'; +import { FileAccess } from '../../../../../../base/common/network.js'; import { basename, posix, win32 } from '../../../../../../base/common/path.js'; import { OperatingSystem, OS } from '../../../../../../base/common/platform.js'; import { count } from '../../../../../../base/common/strings.js'; @@ -86,7 +87,6 @@ import { isSessionAutoApproveLevel, isTerminalAutoApproveAllowed, isToolEligible import type { IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; import { ChatElicitationRequestPart } from '../../../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { getSandboxPrecheckInputsForToolInvocation } from '../../../../chat/browser/tools/toolHelpers.js'; -import { compact } from './consoleCompactor/consoleCompactor.js'; import { IChatSessionsService } from '../../../../chat/common/chatSessionsService.js'; // #region Tool data @@ -2511,19 +2511,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (outputAnalyzerMessage) { resultText.push(`${outputAnalyzerMessage}\n`); } - let outputForResult = terminalResult; - if (this._configurationService.getValue(TerminalChatAgentToolsSettingId.OutputCompaction) === true) { - try { - const commandForCompaction = toolSpecificData.commandLine.forDisplay ?? command; - const report = compact(commandForCompaction, terminalResult); - this._telemetry.logCompaction(report); - if (report.applied) { - outputForResult = report.compactedOutput; - } - } catch { - this._telemetry.logCompactionFailed(); - } - } + const outputForResult = await this._compactOutput(toolSpecificData.commandLine.forDisplay ?? command, terminalResult); // Process large output: write to file if needed, then truncate with file path const processedOutput = await this._largeOutputFileWriter.processOutput(outputForResult); resultText.push(processedOutput); @@ -2634,6 +2622,27 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return lines.join('\n'); } + protected _loadConsoleCompactor(): Promise { + return import(/* webpackIgnore: true */FileAccess.asBrowserUri('vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/consoleCompactor/consoleCompactor.js').toString(true)); + } + + protected async _compactOutput(command: string, output: string): Promise { + if (this._configurationService.getValue(TerminalChatAgentToolsSettingId.OutputCompaction) !== true) { + return output; + } + + try { + const { compact } = await this._loadConsoleCompactor(); + const report = compact(command, output); + this._telemetry.logCompaction(report); + return report.applied ? report.compactedOutput : output; + } catch (error) { + this._logService.warn('RunInTerminalTool: Failed to compact terminal output', error); + this._telemetry.logCompactionFailed(); + return output; + } + } + private async _getOutputAnalyzerMessage(exitCode: number | undefined, exitResult: string, commandLine: string, isSandboxWrapped: boolean): Promise { for (const analyzer of this._outputAnalyzers) { const message = await analyzer.analyze({ exitCode, exitResult, commandLine, isSandboxWrapped }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 9e5b15a0a7ada..e77416c300cb9 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ok, strictEqual } from 'assert'; +import { deepStrictEqual, ok, strictEqual } from 'assert'; import { Separator } from '../../../../../../base/common/actions.js'; import { DeferredPromise } from '../../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; @@ -26,7 +26,7 @@ import type { TestInstantiationService } from '../../../../../../platform/instan import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; -import { ITerminalProfile } from '../../../../../../platform/terminal/common/terminal.js'; +import { ITerminalLogService, ITerminalProfile } from '../../../../../../platform/terminal/common/terminal.js'; import { IWorkspaceContextService, toWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { Workspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; import { IHistoryService } from '../../../../../services/history/common/history.js'; @@ -67,6 +67,22 @@ import { IChatSessionsService } from '../../../../chat/common/chatSessionsServic class TestRunInTerminalTool extends RunInTerminalTool { protected override _osBackend: Promise = Promise.resolve(OperatingSystem.Windows); + compactorLoadCount = 0; + compactorLoadError: Error | undefined; + + protected override async _loadConsoleCompactor() { + this.compactorLoadCount++; + if (this.compactorLoadError) { + throw this.compactorLoadError; + } + // The unit runner uses file:// rather than the workbench's vscode-file:// resource protocol. + return import('../../browser/tools/consoleCompactor/consoleCompactor.js'); + } + + compactOutput(command: string, output: string): Promise { + return this._compactOutput(command, output); + } + get sessionTerminalAssociations() { return this._sessionTerminalAssociations; } get sessionTerminalInstances() { return this._sessionTerminalInstances; } get profileFetcher() { return this._profileFetcher; } @@ -102,6 +118,7 @@ suite('RunInTerminalTool', () => { let createTerminalCallCount: number; let chatSessions: Map; let chatSessionContribution: ReturnType; + let compactionWarnings: { message: string; details: string[] }[]; let runInTerminalTool: TestRunInTerminalTool; @@ -112,6 +129,7 @@ suite('RunInTerminalTool', () => { setup(() => { configurationService = new TestConfigurationService(); workspaceContextService = new TestContextService(); + compactionWarnings = []; const logService = new NullLogService(); fileService = store.add(new FileService(logService)); @@ -202,6 +220,12 @@ suite('RunInTerminalTool', () => { configurationService: () => configurationService, fileService: () => fileService, }, store); + instantiationService.stub(ITerminalLogService, store.add(new class extends NullLogService { + declare readonly _logBrand: undefined; + override warn(message: string, ...args: unknown[]): void { + compactionWarnings.push({ message, details: args.map(arg => String(arg)) }); + } + }())); const chatServiceStub = { onDidDisposeSession: chatServiceDisposeEmitter.event, @@ -460,6 +484,52 @@ suite('RunInTerminalTool', () => { return getAutomaticSandboxRetryTitle(tool, 'allowNetwork', shellType, blockedDomains); } + suite('output compaction', () => { + test('does not load the compactor when compaction is disabled', async () => { + setConfig(TerminalChatAgentToolsSettingId.OutputCompaction, false); + const output = await runInTerminalTool.compactOutput('echo hello', 'hello\n'); + + deepStrictEqual({ output, loads: runInTerminalTool.compactorLoadCount }, { output: 'hello\n', loads: 0 }); + }); + + test('loads the compactor on first use and preserves unremarkable output', async () => { + setConfig(TerminalChatAgentToolsSettingId.OutputCompaction, true); + const loadsBeforeUse = runInTerminalTool.compactorLoadCount; + const output = await runInTerminalTool.compactOutput('echo hello', 'hello\n'); + + deepStrictEqual({ loadsBeforeUse, output, loads: runInTerminalTool.compactorLoadCount, warnings: compactionWarnings }, { loadsBeforeUse: 0, output: 'hello\n', loads: 1, warnings: [] }); + }); + + test('compacts noisy output through the dynamically loaded module', async () => { + setConfig(TerminalChatAgentToolsSettingId.OutputCompaction, true); + const original = Array.from({ length: 400 }, (_, i) => `npm http fetch GET 200 https://registry.npmjs.org/pkg${i} ${i}ms (cache miss)`).join('\n') + '\nadded 400 packages in 3s\n'; + const output = await runInTerminalTool.compactOutput('npm install', original); + + deepStrictEqual({ + compacted: output.length < original.length, + retainsSummary: output.includes('added 400 packages in 3s'), + loads: runInTerminalTool.compactorLoadCount, + warnings: compactionWarnings, + }, { compacted: true, retainsSummary: true, loads: 1, warnings: [] }); + }); + + test('logs a load failure and preserves the command output', async () => { + setConfig(TerminalChatAgentToolsSettingId.OutputCompaction, true); + runInTerminalTool.compactorLoadError = new Error('Compactor could not be loaded'); + const output = await runInTerminalTool.compactOutput('echo hello', 'hello\n'); + + deepStrictEqual({ + output, + loads: runInTerminalTool.compactorLoadCount, + warnings: compactionWarnings, + }, { + output: 'hello\n', + loads: 1, + warnings: [{ message: 'RunInTerminalTool: Failed to compact terminal output', details: ['Error: Compactor could not be loaded'] }], + }); + }); + }); + suite('sandbox invocation messaging', () => { test('should instruct models to use $TMPDIR instead of /tmp when sandboxed', async () => { sandboxEnabled = true;