Skip to content

add basic regex auto-reply support #257691

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
66 changes: 66 additions & 0 deletions src/vs/workbench/contrib/elicitation/browser/elicitation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { MarkdownString } from '../../../../base/common/htmlContent.js';
import { localize } from '../../../../nls.js';
import { ChatElicitationRequestPart } from '../../chat/browser/chatElicitationRequestPart.js';
import { ChatModel } from '../../chat/common/chatModel.js';
import { IChatService } from '../../chat/common/chatService.js';
import { IToolInvocationContext } from '../../chat/common/languageModelToolsService.js';

function createYesNoPrompt(
context: IToolInvocationContext,
chatService: IChatService,
title: string | MarkdownString,
description: string | MarkdownString
): { promise: Promise<boolean>; part?: ChatElicitationRequestPart } {
const chatModel = chatService.getSession(context.sessionId);
if (chatModel instanceof ChatModel) {
const request = chatModel.getRequests().at(-1);
if (request) {
let part: ChatElicitationRequestPart | undefined = undefined;
const promise = new Promise<boolean>(resolve => {
const thePart = part = new ChatElicitationRequestPart(
title,
description,
'',
localize('poll.terminal.accept', 'Yes'),
localize('poll.terminal.reject', 'No'),
async () => {
thePart.state = 'accepted';
thePart.hide();
resolve(true);
},
async () => {
thePart.state = 'rejected';
thePart.hide();
resolve(false);
}
);
chatModel.acceptResponseProgress(request, thePart);
});
return { promise, part };
}
}
return { promise: Promise.resolve(false) };
}

export function promptForMorePolling(title: string, message: string, context: IToolInvocationContext, chatService: IChatService): { promise: Promise<boolean>; part?: ChatElicitationRequestPart } {
return createYesNoPrompt(
context,
chatService,
title,
message
);
}

export function promptForYesNo(title: string | MarkdownString, message: string | MarkdownString, context: IToolInvocationContext, chatService: IChatService): { promise: Promise<boolean>; part?: ChatElicitationRequestPart } {
return createYesNoPrompt(
context,
chatService,
title,
message
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js';
import { localize } from '../../../../../nls.js';
import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';
import { ChatElicitationRequestPart } from '../../../chat/browser/chatElicitationRequestPart.js';
import { ChatModel } from '../../../chat/common/chatModel.js';
import { IChatService } from '../../../chat/common/chatService.js';
import { ChatMessageRole, ILanguageModelsService } from '../../../chat/common/languageModels.js';
import { IToolInvocationContext } from '../../../chat/common/languageModelToolsService.js';
import { IToolInvocationContext, IToolResult } from '../../../chat/common/languageModelToolsService.js';
import { promptForYesNo } from '../../../elicitation/browser/elicitation.js';
import { ITerminalInstance } from '../../../terminal/browser/terminal.js';
import type { IMarker as IXtermMarker } from '@xterm/xterm';

Expand Down Expand Up @@ -152,38 +152,43 @@ export async function pollForOutputAndIdle(
return { terminalExecutionIdleBeforeTimeout: false, output: buffer, pollDurationMs: Date.now() - pollStartTime + (extendedPolling ? PollingConsts.FirstPollingMaxDuration : 0) };
}

export function promptForMorePolling(command: string, context: IToolInvocationContext, chatService: IChatService): { promise: Promise<boolean>; part?: ChatElicitationRequestPart } {
const chatModel = chatService.getSession(context.sessionId);
if (chatModel instanceof ChatModel) {
const request = chatModel.getRequests().at(-1);
if (request) {
let part: ChatElicitationRequestPart | undefined = undefined;
const promise = new Promise<boolean>(resolve => {
const thePart = part = new ChatElicitationRequestPart(
new MarkdownString(localize('poll.terminal.waiting', "Continue waiting for `{0}` to finish?", command)),
new MarkdownString(localize('poll.terminal.polling', "Copilot will continue to poll for output to determine when the terminal becomes idle for up to 2 minutes.")),
'',
localize('poll.terminal.accept', 'Yes'),
localize('poll.terminal.reject', 'No'),
async () => {
thePart.state = 'accepted';
thePart.hide();
resolve(true);
},
async () => {
thePart.state = 'rejected';
thePart.hide();
resolve(false);
}
);
chatModel.acceptResponseProgress(request, thePart);
});
return { promise, part };
export async function handleTerminalUserInputPrompt(
outputAndIdle: { output: string; terminalExecutionIdleBeforeTimeout: boolean; pollDurationMs?: number; modelOutputEvalResponse?: string },
invocation: any,
chatService: IChatService,
terminal: ITerminalInstance,
executableLabel: string,
token: CancellationToken,
languageModelsService: ILanguageModelsService
): Promise<{ handled: boolean; userResponse?: 'accepted' | 'rejected'; outputAndIdle: { output: string; terminalExecutionIdleBeforeTimeout: boolean; pollDurationMs?: number; modelOutputEvalResponse?: string }; message?: IToolResult }> {
const userInputKind = await getExpectedUserInputKind(outputAndIdle.output);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const userInputKind = await getExpectedUserInputKind(outputAndIdle.output);
const userInputKind = getExpectedUserInputKind(outputAndIdle.output);

if (userInputKind) {
const handleResult = await handleYesNoUserPrompt(
userInputKind,
invocation.context,
chatService,
terminal,
async () => await pollForOutputAndIdle({ getOutput: () => terminal.xterm?.raw?.buffer.active.toString() ?? '', isActive: undefined }, true, token, languageModelsService),
);
if (handleResult.handled) {
if (handleResult.outputAndIdle) {
return { handled: true, outputAndIdle: handleResult.outputAndIdle };
}
} else {
return {
handled: false,
outputAndIdle,
message: {
content: [{ kind: 'text', value: `The command is still running and requires user input of ${userInputKind}.` }],
toolResultMessage: new MarkdownString(localize('copilotChat.taskRequiresUserInput', '`{0}` is still running and requires user input. {1}', executableLabel, userInputKind))
}
};
}
}
return { promise: Promise.resolve(false) };
return { handled: false, outputAndIdle };
}


export async function assessOutputForErrors(buffer: string, token: CancellationToken, languageModelsService: ILanguageModelsService): Promise<string> {
const models = await languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4o-mini' });
if (!models.length) {
Expand Down Expand Up @@ -215,3 +220,88 @@ export async function assessOutputForErrors(buffer: string, token: CancellationT
return 'Error occurred ' + err;
}
}

/**
* Returns true if the last line of the output is a prompt asking for user input (e.g., "Do you want to continue? (y/n)").
* This is a heuristic and matches common prompt patterns.
*/
/**
* Returns the kind of input expected by the last line of the output if it appears to be a user prompt.
* For example, returns 'y/n', 'yes/no', 'press key', 'choice', etc., or undefined if not a prompt.
*/
export function getExpectedUserInputKind(output: string): string | undefined {
if (!output) {
return undefined;
}

// Only match if the last non-empty line matches a prompt pattern and the next line is blank
const lines = output.split('\n');
for (let i = lines.length - 2; i >= 0; i--) {
const line = lines[i].trim();
const nextLine = lines[i + 1]?.trim();
if (!line) {
continue;
}
// Each pattern includes a comment with an example command that produces such a prompt
const patterns: { regex: RegExp; kind: string }[] = [
// Generic: contains (y/n)
// Example: apt-get install foo, rm -i file.txt, git clean -fd, etc.
{ regex: /\(y\/n\)/ig, kind: 'y/n' },
// [y/n] prompt (alternative format)
// Example: some package managers
{ regex: /\[y\/n\]/ig, kind: 'y/n' },
// yes/no prompt (matches most common forms)
// Example: sudo shutdown now, custom bash scripts
{ regex: /yes\/no\//ig, kind: 'yes/no' },
// PowerShell Remove-Item -Confirm
// Example: Remove-Item file.txt
{ regex: /\[Y\] Yes\s+\[A\] Yes to All\s+\[N\] No\s+\[L\] No to All\s+\[S\] Suspend\s+\[\?\] Help \(default is ".*"\):/i, kind: 'pwsh choice' },
// Choice prompt
// Example: interactive install scripts, menu-driven CLI tools
{ regex: /(enter your choice|select an option)/ig, kind: 'choice' },
// Response prompt
// Example: expect scripts
{ regex: /please respond/ig, kind: 'response' },
];
for (const { regex, kind } of patterns) {
if (regex.test(line) && (!nextLine || nextLine === '')) {
return kind;
}
}
break;
}
return undefined;
}

/**
* Handles a yes/no user prompt for terminal output, sending the appropriate response to the terminal.
* Returns true if a response was sent, false if not handled.
*/
export async function handleYesNoUserPrompt(
userInputKind: string,
context: IToolInvocationContext,
chatService: IChatService,
terminal: ITerminalInstance,
pollForOutputAndIdleFn: () => Promise<{ terminalExecutionIdleBeforeTimeout: boolean; output: string; pollDurationMs?: number; modelOutputEvalResponse?: string }>,
): Promise<{ handled: boolean; userResponse?: 'accepted' | 'rejected'; outputAndIdle?: { terminalExecutionIdleBeforeTimeout: boolean; output: string; pollDurationMs?: number; modelOutputEvalResponse?: string } }> {
if (userInputKind && userInputKind !== 'choice' && userInputKind !== 'key') {
const options = userInputKind.split('/');
const acceptAnswer = options[0]?.trim();
const rejectAnswer = options[1]?.trim();
if (!acceptAnswer || !rejectAnswer) {
return { handled: false };
}
const response = await promptForYesNo(new MarkdownString(localize('poll.terminal.yes', 'Respond `{0}` in the terminal?', acceptAnswer)),
localize('poll.terminal.yesNo', 'Copilot will run the reply in the terminal.'), context, chatService);
const result = await response.promise;
if (result) {
await terminal.sendText(acceptAnswer, true);
const outputAndIdle = await pollForOutputAndIdleFn();
return { handled: true, outputAndIdle, userResponse: 'accepted' };
} else {
await terminal.sendText(rejectAnswer, true);
return { handled: true, userResponse: 'rejected' };
}
}
return { handled: false };
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ import { isPowerShell } from './runInTerminalHelpers.js';
import { extractInlineSubCommands, splitCommandLineIntoSubCommands } from './subCommands.js';
import { ShellIntegrationQuality, ToolTerminalCreator, type IToolTerminal } from './toolTerminalCreator.js';
import { ILanguageModelsService } from '../../../chat/common/languageModels.js';
import { getOutput, pollForOutputAndIdle, promptForMorePolling, racePollingOrPrompt } from './bufferOutputPolling.js';
import { getOutput, handleTerminalUserInputPrompt, pollForOutputAndIdle, racePollingOrPrompt } from './bufferOutputPolling.js';
import { promptForMorePolling } from '../../../elicitation/browser/elicitation.js';

const TERMINAL_SESSION_STORAGE_KEY = 'chat.terminalSessions';

Expand Down Expand Up @@ -266,6 +267,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
}

let error: string | undefined;
let userResponse: 'accepted' | 'rejected' | undefined;

const timingStart = Date.now();
const termId = generateUuid();
Expand Down Expand Up @@ -297,14 +299,30 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
if (!outputAndIdle.terminalExecutionIdleBeforeTimeout) {
outputAndIdle = await racePollingOrPrompt(
() => pollForOutputAndIdle(execution, true, token, this._languageModelsService),
() => promptForMorePolling(command, invocation.context!, this._chatService),
() => promptForMorePolling(localize('poll.terminal.waiting', "Continue waiting for `{0}` to finish?", command), localize('poll.terminal.polling', "Copilot will continue to poll for output to determine when the terminal becomes idle for up to 2 minutes."), invocation.context!, this._chatService),
outputAndIdle,
token,
this._languageModelsService,
execution
);
}

const handleResult = await handleTerminalUserInputPrompt(
outputAndIdle,
invocation,
this._chatService,
toolTerminal.instance,
command,
token,
this._languageModelsService
);
if (handleResult.handled) {
if (handleResult.outputAndIdle) {
outputAndIdle = handleResult.outputAndIdle;
}
userResponse = handleResult.userResponse;
} else if (handleResult.message) {
return handleResult.message;
}
let resultText = (
didUserEditCommand
? `Note: The user manually edited the command to \`${command}\`, and that command is now running in terminal with ID=${termId}`
Expand Down Expand Up @@ -347,6 +365,11 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
terminalExecutionIdleBeforeTimeout: outputAndIdle?.terminalExecutionIdleBeforeTimeout,
outputLineCount: outputAndIdle?.output ? count(outputAndIdle.output, '\n') : 0,
pollDurationMs: outputAndIdle?.pollDurationMs,
// TODO: Fill in tool input properties
inputToolManualAcceptCount: 0,
inputToolManualRejectCount: 0,
inputToolManualChars: 0,
inputToolAutoChars: 0,
});
}
} else {
Expand Down Expand Up @@ -432,6 +455,11 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
exitCode,
timingExecuteMs,
timingConnectMs,
// TODO: Support tool auto reply in foreground terminals https://github.com/microsoft/vscode/issues/257726
inputToolManualAcceptCount: 0,
inputToolManualRejectCount: 0,
inputToolManualChars: 0,
inputToolAutoChars: 0,
});
}

Expand Down Expand Up @@ -603,6 +631,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
terminalExecutionIdleBeforeTimeout?: boolean;
timingExecuteMs: number;
exitCode: number | undefined;
inputToolManualAcceptCount: number;
inputToolManualRejectCount: number;
inputToolManualChars: number;
inputToolAutoChars: number;
}) {
type TelemetryEvent = {
terminalSessionId: string;
Expand All @@ -619,6 +651,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
timingConnectMs: number;
pollDurationMs: number;
terminalExecutionIdleBeforeTimeout: boolean;
inputToolManualAcceptCount: number;
inputToolManualRejectCount: number;
inputToolManualChars: number;
inputToolAutoChars: number;
};
type TelemetryClassification = {
owner: 'tyriar';
Expand All @@ -638,9 +674,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
timingConnectMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'How long the terminal took to start up and connect to' };
pollDurationMs: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'How long the tool polled for output, this is undefined when isBackground is true or if there\'s an error' };
terminalExecutionIdleBeforeTimeout: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Indicates whether a terminal became idle before the run-in-terminal tool timed out or was cancelled by the user. This occurs when no data events are received twice consecutively and the model determines, based on terminal output, that the command has completed.' };

inputToolManualAcceptCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of times an elicitation to respond in the terminal was manually accepted by the user.' };
inputToolManualRejectCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of times an elicitation to respond in the terminal was manually rejected by the user.' };
inputToolManualChars: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of characters the tool input manually after accepting an elicitation dialog.' };
inputToolAutoChars: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of characters the tool input automatically without user confirmation.' };
};
this._telemetryService.publicLog2<TelemetryEvent, TelemetryClassification>('toolUse.runInTerminal', {
terminalSessionId: instance.sessionId,

result: state.error ?? 'success',
strategy: state.shellIntegrationQuality === ShellIntegrationQuality.Rich ? 2 : state.shellIntegrationQuality === ShellIntegrationQuality.Basic ? 1 : 0,
userEditedCommand: state.didUserEditCommand ? 1 : 0,
Expand All @@ -652,7 +694,11 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
nonZeroExitCode: state.exitCode === undefined ? -1 : state.exitCode === 0 ? 0 : 1,
timingConnectMs: state.timingConnectMs,
pollDurationMs: state.pollDurationMs ?? 0,
terminalExecutionIdleBeforeTimeout: state.terminalExecutionIdleBeforeTimeout ?? false
terminalExecutionIdleBeforeTimeout: state.terminalExecutionIdleBeforeTimeout ?? false,
inputToolManualAcceptCount: state.inputToolManualAcceptCount,
inputToolManualRejectCount: state.inputToolManualRejectCount,
inputToolManualChars: state.inputToolManualChars,
inputToolAutoChars: state.inputToolAutoChars
});
}
}
Expand Down
Loading
Loading