Add send_to_terminal tool for sending commands to background terminals (#306875)

This commit is contained in:
Megan Rogge
2026-03-31 16:09:02 -04:00
committed by GitHub
parent 17faef0d3c
commit 79a97517fb
6 changed files with 286 additions and 6 deletions

View File

@@ -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[] = [];

View File

@@ -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);

View File

@@ -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<IStreamedToolInvocation | undefined> {
const partialInput = context.rawInput as Partial<IRunInTerminalInputParams> | 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));

View File

@@ -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<IPreparedToolInvocation | undefined> {
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<IToolResult> {
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.`
}]
};
}
}

View File

@@ -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',

View File

@@ -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');
});
});