Skip to content

Commit b75f5d8

Browse files
authored
add bg terminal output polling and cancellation option (microsoft#256613)
1 parent 092bbc8 commit b75f5d8

File tree

6 files changed

+229
-45
lines changed

6 files changed

+229
-45
lines changed

src/vs/workbench/contrib/chat/browser/chatContentParts/chatElicitationContentPart.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import { Emitter } from '../../../../../base/common/event.js';
77
import { IMarkdownString, isMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js';
88
import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js';
9-
import { localize } from '../../../../../nls.js';
109
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
1110
import { IChatProgressRenderableResponseContent } from '../../common/chatModel.js';
1211
import { IChatElicitationRequest } from '../../common/chatService.js';
@@ -27,8 +26,8 @@ export class ChatElicitationContentPart extends Disposable implements IChatConte
2726
super();
2827

2928
const buttons = [
30-
{ label: localize('accept', "Respond"), data: true },
31-
{ label: localize('dismiss', "Cancel"), data: false, isSecondary: true },
29+
{ label: elicitation.acceptButtonLabel, data: true },
30+
{ label: elicitation.rejectButtonLabel, data: false, isSecondary: true },
3231
];
3332
const confirmationWidget = this._register(this.instantiationService.createInstance(ChatConfirmationWidget, elicitation.title, elicitation.originMessage, this.getMessageToRender(elicitation), buttons, context.container));
3433
confirmationWidget.setShowButtons(elicitation.state === 'pending');
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { IMarkdownString } from '../../../../base/common/htmlContent.js';
7+
import { IChatElicitationRequest } from '../common/chatService.js';
8+
9+
export class ChatElicitationRequestPart implements IChatElicitationRequest {
10+
public readonly kind = 'elicitation';
11+
public state: 'pending' | 'accepted' | 'rejected' = 'pending';
12+
public acceptedResult?: Record<string, unknown>;
13+
14+
constructor(
15+
public readonly title: string | IMarkdownString,
16+
public readonly message: string | IMarkdownString,
17+
public readonly originMessage: string | IMarkdownString,
18+
public readonly acceptButtonLabel: string,
19+
public readonly rejectButtonLabel: string,
20+
public readonly accept: () => Promise<void>,
21+
public readonly reject: () => Promise<void>,
22+
) { }
23+
24+
public toJSON() {
25+
return {
26+
kind: 'elicitation',
27+
title: this.title,
28+
message: this.message,
29+
state: this.state === 'pending' ? 'rejected' : this.state,
30+
acceptedResult: this.acceptedResult,
31+
} satisfies Partial<IChatElicitationRequest>;
32+
}
33+
}

src/vs/workbench/contrib/chat/common/chatService.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,8 @@ export interface IChatElicitationRequest {
231231
kind: 'elicitation';
232232
title: string | IMarkdownString;
233233
message: string | IMarkdownString;
234+
acceptButtonLabel: string;
235+
rejectButtonLabel: string;
234236
originMessage?: string | IMarkdownString;
235237
state: 'pending' | 'accepted' | 'rejected';
236238
acceptedResult?: Record<string, unknown>;

src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
import { Action } from '../../../../base/common/actions.js';
77
import { assertNever } from '../../../../base/common/assert.js';
88
import { CancellationToken } from '../../../../base/common/cancellation.js';
9-
import { IMarkdownString, markdownCommandLink, MarkdownString } from '../../../../base/common/htmlContent.js';
9+
import { markdownCommandLink, MarkdownString } from '../../../../base/common/htmlContent.js';
1010
import { DisposableStore } from '../../../../base/common/lifecycle.js';
1111
import { localize } from '../../../../nls.js';
1212
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
1313
import { IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js';
14+
import { ChatElicitationRequestPart } from '../../chat/browser/chatElicitationRequestPart.js';
1415
import { ChatModel } from '../../chat/common/chatModel.js';
15-
import { IChatElicitationRequest, IChatService } from '../../chat/common/chatService.js';
16+
import { IChatService } from '../../chat/common/chatService.js';
1617
import { McpCommandIds } from '../common/mcpCommandIds.js';
1718
import { IMcpElicitationService, IMcpServer, IMcpToolCallContext } from '../common/mcpTypes.js';
1819
import { MCP } from '../common/modelContextProtocol.js';
@@ -43,6 +44,8 @@ export class McpElicitationService implements IMcpElicitationService {
4344
title: localize('msg.subtitle', "{0} (MCP Server)", server.definition.label),
4445
arguments: [server.collection.id, server.definition.id],
4546
}), { isTrusted: true }),
47+
localize('mcp.elicit.accept', 'Respond'),
48+
localize('mcp.elicit.reject', 'Cancel'),
4649
async () => {
4750
const p = this._doElicit(elicitation, token);
4851
resolve(p);
@@ -320,27 +323,3 @@ export class McpElicitationService implements IMcpElicitationService {
320323
return { isValid: true, parsedValue: parsed };
321324
}
322325
}
323-
324-
class ChatElicitationRequestPart implements IChatElicitationRequest {
325-
public readonly kind = 'elicitation';
326-
public state: 'pending' | 'accepted' | 'rejected' = 'pending';
327-
public acceptedResult?: Record<string, unknown>;
328-
329-
constructor(
330-
public readonly title: string | IMarkdownString,
331-
public readonly message: string | IMarkdownString,
332-
public readonly originMessage: string | IMarkdownString,
333-
public readonly accept: () => Promise<void>,
334-
public readonly reject: () => Promise<void>,
335-
) { }
336-
337-
public toJSON() {
338-
return {
339-
kind: 'elicitation',
340-
title: this.title,
341-
message: this.message,
342-
state: this.state === 'pending' ? 'rejected' : this.state,
343-
acceptedResult: this.acceptedResult,
344-
} satisfies Partial<IChatElicitationRequest>;
345-
}
346-
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { timeout } from '../../../../../base/common/async.js';
7+
import { CancellationToken } from '../../../../../base/common/cancellation.js';
8+
import { MarkdownString } from '../../../../../base/common/htmlContent.js';
9+
import { localize } from '../../../../../nls.js';
10+
import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';
11+
import { ChatElicitationRequestPart } from '../../../chat/browser/chatElicitationRequestPart.js';
12+
import { ChatModel } from '../../../chat/common/chatModel.js';
13+
import { IChatService } from '../../../chat/common/chatService.js';
14+
import { ChatMessageRole, ILanguageModelsService } from '../../../chat/common/languageModels.js';
15+
import { IToolInvocationContext } from '../../../chat/common/languageModelToolsService.js';
16+
import { ITerminalInstance } from '../../../terminal/browser/terminal.js';
17+
import type { IMarker as IXtermMarker } from '@xterm/xterm';
18+
19+
const enum PollingConsts {
20+
MinNoDataEvents = 2, // Minimum number of no data checks before considering the terminal idle
21+
MinPollingDuration = 500,
22+
FirstPollingMaxDuration = 20000, // 20 seconds
23+
ExtendedPollingMaxDuration = 120000, // 2 minutes
24+
MaxPollingIntervalDuration = 2000, // 2 seconds
25+
}
26+
27+
export function getOutput(instance: ITerminalInstance, startMarker?: IXtermMarker): string {
28+
if (!instance.xterm || !instance.xterm.raw) {
29+
return '';
30+
}
31+
const lines: string[] = [];
32+
for (let y = Math.min(startMarker?.line ?? 0, 0); y < instance.xterm!.raw.buffer.active.length; y++) {
33+
const line = instance.xterm!.raw.buffer.active.getLine(y);
34+
if (!line) {
35+
continue;
36+
}
37+
lines.push(line.translateToString(true));
38+
}
39+
return lines.join('\n');
40+
}
41+
42+
export async function pollForOutputAndIdle(
43+
execution: { getOutput: () => string },
44+
extendedPolling: boolean,
45+
token: CancellationToken,
46+
languageModelsService: ILanguageModelsService,
47+
): Promise<{ terminalExecutionIdleBeforeTimeout: boolean; output: string; pollDurationMs?: number }> {
48+
const maxWaitMs = extendedPolling ? PollingConsts.ExtendedPollingMaxDuration : PollingConsts.FirstPollingMaxDuration;
49+
const maxInterval = PollingConsts.MaxPollingIntervalDuration;
50+
let currentInterval = PollingConsts.MinPollingDuration;
51+
const pollStartTime = Date.now();
52+
53+
let lastBufferLength = 0;
54+
let noNewDataCount = 0;
55+
let buffer = '';
56+
let terminalExecutionIdleBeforeTimeout = false;
57+
58+
while (true) {
59+
if (token.isCancellationRequested) {
60+
break;
61+
}
62+
const now = Date.now();
63+
const elapsed = now - pollStartTime;
64+
const timeLeft = maxWaitMs - elapsed;
65+
66+
if (timeLeft <= 0) {
67+
break;
68+
}
69+
70+
// Cap the wait so we never overshoot timeLeft
71+
const waitTime = Math.min(currentInterval, timeLeft);
72+
await timeout(waitTime);
73+
74+
// Check again immediately after waking
75+
if (Date.now() - pollStartTime >= maxWaitMs) {
76+
break;
77+
}
78+
79+
currentInterval = Math.min(currentInterval * 2, maxInterval);
80+
81+
buffer = execution.getOutput();
82+
const currentBufferLength = buffer.length;
83+
84+
if (currentBufferLength === lastBufferLength) {
85+
noNewDataCount++;
86+
} else {
87+
noNewDataCount = 0;
88+
lastBufferLength = currentBufferLength;
89+
}
90+
const isLikelyFinished = await assessOutputForFinishedState(buffer, token, languageModelsService);
91+
terminalExecutionIdleBeforeTimeout = isLikelyFinished && noNewDataCount >= PollingConsts.MinNoDataEvents;
92+
if (terminalExecutionIdleBeforeTimeout) {
93+
return { terminalExecutionIdleBeforeTimeout, output: buffer, pollDurationMs: Date.now() - pollStartTime + (extendedPolling ? PollingConsts.FirstPollingMaxDuration : 0) };
94+
}
95+
}
96+
return { terminalExecutionIdleBeforeTimeout, output: buffer, pollDurationMs: Date.now() - pollStartTime + (extendedPolling ? PollingConsts.FirstPollingMaxDuration : 0) };
97+
}
98+
99+
export async function promptForMorePolling(command: string, execution: { getOutput: () => string }, token: CancellationToken, context: IToolInvocationContext, chatService: IChatService): Promise<boolean> {
100+
const chatModel = chatService.getSession(context.sessionId);
101+
if (chatModel instanceof ChatModel) {
102+
const request = chatModel.getRequests().at(-1);
103+
if (request) {
104+
const waitPromise = new Promise<boolean>(resolve => {
105+
const part = new ChatElicitationRequestPart(
106+
new MarkdownString(localize('poll.terminal.waiting', "Continue waiting for `{0}` to finish?", command)),
107+
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.")),
108+
'',
109+
localize('poll.terminal.accept', 'Yes'),
110+
localize('poll.terminal.reject', 'No'),
111+
async () => {
112+
resolve(true);
113+
},
114+
async () => {
115+
resolve(false);
116+
}
117+
);
118+
chatModel.acceptResponseProgress(request, part);
119+
});
120+
return waitPromise;
121+
}
122+
}
123+
return false; // Fallback to not waiting if we can't prompt the user
124+
}
125+
126+
export async function assessOutputForFinishedState(buffer: string, token: CancellationToken, languageModelsService: ILanguageModelsService): Promise<boolean> {
127+
const models = await languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4o-mini' });
128+
if (!models.length) {
129+
return false;
130+
}
131+
132+
const response = await languageModelsService.sendChatRequest(models[0], new ExtensionIdentifier('Github.copilot-chat'), [{ role: ChatMessageRole.Assistant, content: [{ type: 'text', value: `Evaluate this terminal output to determine if the command is finished or still in process: ${buffer}. Return the word true if finished and false if still in process.` }] }], {}, token);
133+
134+
let responseText = '';
135+
136+
const streaming = (async () => {
137+
for await (const part of response.stream) {
138+
if (Array.isArray(part)) {
139+
for (const p of part) {
140+
if (p.part.type === 'text') {
141+
responseText += p.part.value;
142+
}
143+
}
144+
} else if (part.part.type === 'text') {
145+
responseText += part.part.value;
146+
}
147+
}
148+
})();
149+
150+
try {
151+
await Promise.all([response.result, streaming]);
152+
return responseText.includes('true');
153+
} catch (err) {
154+
return false;
155+
}
156+
}

0 commit comments

Comments
 (0)