mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-02 08:15:56 +01:00
Add send_to_terminal tool for sending commands to background terminals (#306875)
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.`
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user