From 79a97517fbde6ece504bfa4bb8575b867a8635bf Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Tue, 31 Mar 2026 16:09:02 -0400 Subject: [PATCH] Add `send_to_terminal` tool for sending commands to background terminals (#306875) --- .../browser/runInTerminalHelpers.ts | 17 ++ .../terminal.chatAgentTools.contribution.ts | 5 + .../browser/tools/runInTerminalTool.ts | 11 +- .../browser/tools/sendToTerminalTool.ts | 94 ++++++++++ .../chatAgentTools/browser/tools/toolIds.ts | 1 + .../test/browser/sendToTerminalTool.test.ts | 164 ++++++++++++++++++ 6 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts create mode 100644 src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts index 4edc69a5fad..da4024062f4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/runInTerminalHelpers.ts @@ -85,6 +85,23 @@ export function normalizeTerminalCommandForDisplay(commandLine: string): string return commandLine.replace(/\\(["'\/])/g, '$1'); } +/** + * Builds a single-line display string for a terminal command, suitable for UI messages. + * Normalizes escape artifacts, collapses newlines to spaces, and truncates to 80 characters. + */ +export function buildCommandDisplayText(command: string): string { + const normalized = normalizeTerminalCommandForDisplay(command).replace(/\r\n|\r|\n/g, ' '); + return normalized.length > 80 ? normalized.substring(0, 77) + '...' : normalized; +} + +/** + * Normalizes a terminal command for execution by collapsing newlines to spaces. + * This prevents multi-line input from being sent as multiple commands via sendText. + */ +export function normalizeCommandForExecution(command: string): string { + return command.replace(/\r\n|\r|\n/g, ' ').trim(); +} + export function generateAutoApproveActions(commandLine: string, subCommands: string[], autoApproveResult: { subCommandResults: ICommandApprovalResultWithReason[]; commandLineResult: ICommandApprovalResultWithReason }): ToolConfirmationAction[] { const actions: ToolConfirmationAction[] = []; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts index eec2f8c5d81..7798303a537 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts @@ -24,6 +24,7 @@ import { AwaitTerminalTool, AwaitTerminalToolData } from './tools/awaitTerminalT import { GetTerminalLastCommandTool, GetTerminalLastCommandToolData } from './tools/getTerminalLastCommandTool.js'; import { KillTerminalTool, KillTerminalToolData } from './tools/killTerminalTool.js'; import { GetTerminalOutputTool, GetTerminalOutputToolData } from './tools/getTerminalOutputTool.js'; +import { SendToTerminalTool, SendToTerminalToolData } from './tools/sendToTerminalTool.js'; import { GetTerminalSelectionTool, GetTerminalSelectionToolData } from './tools/getTerminalSelectionTool.js'; import { ConfirmTerminalCommandTool, ConfirmTerminalCommandToolData } from './tools/runInTerminalConfirmationTool.js'; import { RunInTerminalTool, createRunInTerminalToolData } from './tools/runInTerminalTool.js'; @@ -105,6 +106,10 @@ export class ChatAgentToolsContribution extends Disposable implements IWorkbench this._register(_toolsService.registerTool(KillTerminalToolData, killTerminalTool)); this._register(_toolsService.executeToolSet.addTool(KillTerminalToolData)); + const sendToTerminalTool = _instantiationService.createInstance(SendToTerminalTool); + this._register(_toolsService.registerTool(SendToTerminalToolData, sendToTerminalTool)); + this._register(_toolsService.executeToolSet.addTool(SendToTerminalToolData)); + this._registerRunInTerminalTool(); const getTerminalSelectionTool = _instantiationService.createInstance(GetTerminalSelectionTool); 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 3ab326afae7..0df6fa55214 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -38,7 +38,7 @@ import type { ITerminalExecuteStrategy, ITerminalExecuteStrategyResult } from '. import { NoneExecuteStrategy } from '../executeStrategy/noneExecuteStrategy.js'; import { RichExecuteStrategy } from '../executeStrategy/richExecuteStrategy.js'; import { getOutput } from '../outputHelpers.js'; -import { extractCdPrefix, isFish, isPowerShell, isWindowsPowerShell, isZsh, normalizeTerminalCommandForDisplay } from '../runInTerminalHelpers.js'; +import { buildCommandDisplayText, extractCdPrefix, isFish, isPowerShell, isWindowsPowerShell, isZsh, normalizeTerminalCommandForDisplay } from '../runInTerminalHelpers.js'; import type { ICommandLinePresenter } from './commandLinePresenter/commandLinePresenter.js'; import { NodeCommandLinePresenter } from './commandLinePresenter/nodeCommandLinePresenter.js'; import { PythonCommandLinePresenter } from './commandLinePresenter/pythonCommandLinePresenter.js'; @@ -108,6 +108,7 @@ function createPowerShellModelDescription(shell: string, isSandboxEnabled: boole 'Background Processes:', '- For long-running tasks (e.g., servers), set isBackground=true', '- Returns a terminal ID for checking status and runtime later', + `- Use ${TerminalToolId.SendToTerminal} to send commands to a background terminal`, '- Use Start-Job for background PowerShell jobs', ]; @@ -184,7 +185,8 @@ Program Execution: Background Processes: - For long-running tasks (e.g., servers), set isBackground=true -- Returns a terminal ID for checking status and runtime later`]; +- Returns a terminal ID for checking status and runtime later +- Use ${TerminalToolId.SendToTerminal} to send commands to a background terminal`]; if (isSandboxEnabled) { parts.push(createSandboxLines(networkDomains).join('\n')); @@ -528,10 +530,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { async handleToolStream(context: IToolInvocationStreamContext, _token: CancellationToken): Promise { const partialInput = context.rawInput as Partial | undefined; if (partialInput && typeof partialInput === 'object' && partialInput.command) { - const normalizedCommand = normalizeTerminalCommandForDisplay(partialInput.command).replace(/\r\n|\r|\n/g, ' '); - const truncatedCommand = normalizedCommand.length > 80 - ? normalizedCommand.substring(0, 77) + '...' - : normalizedCommand; + const truncatedCommand = buildCommandDisplayText(partialInput.command); const invocationMessage = partialInput.isBackground ? new MarkdownString(localize('runInTerminal.streaming.background', "Running `{0}` in background", truncatedCommand)) : new MarkdownString(localize('runInTerminal.streaming', "Running `{0}`", truncatedCommand)); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts new file mode 100644 index 00000000000..40cc4260bb4 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../../nls.js'; +import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; +import { buildCommandDisplayText, normalizeCommandForExecution } from '../runInTerminalHelpers.js'; +import { RunInTerminalTool } from './runInTerminalTool.js'; +import { TerminalToolId } from './toolIds.js'; + +export const SendToTerminalToolData: IToolData = { + id: TerminalToolId.SendToTerminal, + toolReferenceName: 'sendToTerminal', + displayName: localize('sendToTerminalTool.displayName', 'Send to Terminal'), + modelDescription: `Send a command to an existing background terminal that was started with ${TerminalToolId.RunInTerminal}. Use this to send commands to long-running terminal sessions. The ID must be the exact opaque value returned by ${TerminalToolId.RunInTerminal}. After sending, use ${TerminalToolId.GetTerminalOutput} to check for updated output.`, + icon: Codicon.terminal, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + id: { + type: 'string', + description: `The ID of the background terminal to send a command to (returned by ${TerminalToolId.RunInTerminal}).`, + pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' + }, + command: { + type: 'string', + description: 'The command to send to the terminal. The text will be sent followed by Enter to execute it.' + }, + }, + required: [ + 'id', + 'command', + ] + } +}; + +export interface ISendToTerminalInputParams { + id: string; + command: string; +} + +export class SendToTerminalTool extends Disposable implements IToolImpl { + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const args = context.parameters as ISendToTerminalInputParams; + const displayCommand = buildCommandDisplayText(args.command); + + const invocationMessage = new MarkdownString(); + invocationMessage.appendText(localize('send.progressive', "Sending {0} to terminal", displayCommand)); + + const pastTenseMessage = new MarkdownString(); + pastTenseMessage.appendText(localize('send.past', "Sent {0} to terminal", displayCommand)); + + const confirmationMessage = new MarkdownString(); + confirmationMessage.appendText(localize('send.confirm.message', "Run {0} in background terminal {1}", displayCommand, args.id)); + + return { + invocationMessage, + pastTenseMessage, + confirmationMessages: { + title: localize('send.confirm.title', "Send to Terminal"), + message: confirmationMessage, + }, + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const args = invocation.parameters as ISendToTerminalInputParams; + + const execution = RunInTerminalTool.getExecution(args.id); + if (!execution) { + return { + content: [{ + kind: 'text', + value: `Error: No active terminal execution found with ID ${args.id}. The terminal may have already been killed or the ID is invalid. The ID must be the exact value returned by ${TerminalToolId.RunInTerminal}.` + }] + }; + } + + await execution.instance.sendText(normalizeCommandForExecution(args.command), true); + + return { + content: [{ + kind: 'text', + value: `Successfully sent command to terminal ${args.id}. Use ${TerminalToolId.GetTerminalOutput} to check for updated output.` + }] + }; + } +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/toolIds.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/toolIds.ts index adab9944e2c..cc2cfa752f9 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/toolIds.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/toolIds.ts @@ -5,6 +5,7 @@ export const enum TerminalToolId { RunInTerminal = 'run_in_terminal', + SendToTerminal = 'send_to_terminal', AwaitTerminal = 'await_terminal', GetTerminalOutput = 'get_terminal_output', KillTerminal = 'kill_terminal', diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts new file mode 100644 index 00000000000..ba957aa9cb7 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import type { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { SendToTerminalTool, SendToTerminalToolData } from '../../browser/tools/sendToTerminalTool.js'; +import { RunInTerminalTool, type IActiveTerminalExecution } from '../../browser/tools/runInTerminalTool.js'; +import type { IToolInvocation, IToolInvocationPreparationContext } from '../../../../chat/common/tools/languageModelToolsService.js'; +import type { ITerminalExecuteStrategyResult } from '../../browser/executeStrategy/executeStrategy.js'; +import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; + +suite('SendToTerminalTool', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const UNKNOWN_TERMINAL_ID = '123e4567-e89b-12d3-a456-426614174000'; + const KNOWN_TERMINAL_ID = '123e4567-e89b-12d3-a456-426614174001'; + let tool: SendToTerminalTool; + let originalGetExecution: typeof RunInTerminalTool.getExecution; + + setup(() => { + tool = store.add(new SendToTerminalTool()); + originalGetExecution = RunInTerminalTool.getExecution; + }); + + teardown(() => { + RunInTerminalTool.getExecution = originalGetExecution; + }); + + function createInvocation(id: string, command: string): IToolInvocation { + return { + parameters: { id, command }, + callId: 'test-call', + context: { sessionId: 'test-session' }, + toolId: 'send_to_terminal', + tokenBudget: 1000, + isComplete: () => false, + isCancellationRequested: false, + } as unknown as IToolInvocation; + } + + function createMockExecution(output: string): IActiveTerminalExecution & { sentTexts: { text: string; shouldExecute: boolean }[] } { + const sentTexts: { text: string; shouldExecute: boolean }[] = []; + return { + completionPromise: Promise.resolve({ output } as ITerminalExecuteStrategyResult), + instance: { + sendText: async (text: string, shouldExecute: boolean) => { + sentTexts.push({ text, shouldExecute }); + }, + } as unknown as ITerminalInstance, + getOutput: () => output, + sentTexts, + }; + } + + test('tool description documents terminal IDs and use cases', () => { + const idProperty = SendToTerminalToolData.inputSchema?.properties?.id as { description?: string; pattern?: string } | undefined; + assert.ok(SendToTerminalToolData.modelDescription.includes('existing background terminal')); + assert.ok(idProperty?.pattern?.includes('[0-9a-fA-F]{8}')); + }); + + test('returns error for unknown terminal id', async () => { + RunInTerminalTool.getExecution = () => undefined; + + const result = await tool.invoke( + createInvocation(UNKNOWN_TERMINAL_ID, 'ls'), + async () => 0, + { report: () => { } }, + CancellationToken.None, + ); + + assert.strictEqual(result.content.length, 1); + assert.strictEqual(result.content[0].kind, 'text'); + const value = (result.content[0] as { value: string }).value; + assert.ok(value.includes('No active terminal execution found')); + assert.ok(value.includes(UNKNOWN_TERMINAL_ID)); + }); + + test('sends command to terminal and returns acknowledgment', async () => { + const mockExecution = createMockExecution('$ ls\nfile1.txt\nfile2.txt'); + RunInTerminalTool.getExecution = () => mockExecution; + + const result = await tool.invoke( + createInvocation(KNOWN_TERMINAL_ID, 'ls'), + async () => 0, + { report: () => { } }, + CancellationToken.None, + ); + + assert.strictEqual(result.content.length, 1); + assert.strictEqual(result.content[0].kind, 'text'); + const value = (result.content[0] as { value: string }).value; + assert.ok(value.includes('Successfully sent command')); + assert.ok(value.includes(KNOWN_TERMINAL_ID)); + assert.ok(value.includes('get_terminal_output'), 'should direct agent to use get_terminal_output'); + + // Verify sendText was called with shouldExecute=true + assert.strictEqual(mockExecution.sentTexts.length, 1); + assert.strictEqual(mockExecution.sentTexts[0].text, 'ls'); + assert.strictEqual(mockExecution.sentTexts[0].shouldExecute, true); + }); + + test('sends multi-word command correctly', async () => { + const mockExecution = createMockExecution('output'); + RunInTerminalTool.getExecution = () => mockExecution; + + await tool.invoke( + createInvocation(KNOWN_TERMINAL_ID, 'echo hello world'), + async () => 0, + { report: () => { } }, + CancellationToken.None, + ); + + assert.strictEqual(mockExecution.sentTexts.length, 1); + assert.strictEqual(mockExecution.sentTexts[0].text, 'echo hello world'); + assert.strictEqual(mockExecution.sentTexts[0].shouldExecute, true); + }); + + function createPreparationContext(id: string, command: string): IToolInvocationPreparationContext { + return { + parameters: { id, command }, + toolCallId: 'test-call', + } as unknown as IToolInvocationPreparationContext; + } + + test('prepareToolInvocation shows command in messages', async () => { + const prepared = await tool.prepareToolInvocation( + createPreparationContext(KNOWN_TERMINAL_ID, 'ls -la'), + CancellationToken.None, + ); + + assert.ok(prepared); + assert.ok(prepared.invocationMessage); + assert.ok(prepared.pastTenseMessage); + assert.ok(prepared.confirmationMessages); + assert.ok(prepared.confirmationMessages.title); + assert.ok(prepared.confirmationMessages.message); + }); + + test('prepareToolInvocation truncates long commands', async () => { + const longCommand = 'a'.repeat(100); + const prepared = await tool.prepareToolInvocation( + createPreparationContext(KNOWN_TERMINAL_ID, longCommand), + CancellationToken.None, + ); + + assert.ok(prepared); + const message = prepared.invocationMessage as IMarkdownString; + assert.ok(message.value.includes('...')); + }); + + test('prepareToolInvocation normalizes newlines in command', async () => { + const prepared = await tool.prepareToolInvocation( + createPreparationContext(KNOWN_TERMINAL_ID, 'echo hello\necho world'), + CancellationToken.None, + ); + + assert.ok(prepared); + const message = prepared.invocationMessage as IMarkdownString; + assert.ok(!message.value.includes('\n'), 'newlines should be collapsed to spaces'); + }); +});