Skip to content

Commit 73bf4de

Browse files
authored
Skip redundant send_to_terminal confirmation when replying to askQuestions carousel (#309598)
1 parent c6051d7 commit 73bf4de

2 files changed

Lines changed: 230 additions & 34 deletions

File tree

src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts

Lines changed: 55 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -153,12 +153,14 @@ export class SendToTerminalTool extends Disposable implements IToolImpl {
153153
const chatSessionResource = context.chatSessionResource;
154154
const isSessionAutoApproved = chatSessionResource && isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService);
155155

156-
// send_to_terminal always requires confirmation in default approvals mode.
157-
// Unlike run_in_terminal, the text sent here may be arbitrary input to a
158-
// waiting prompt (e.g. a name, password, or confirmation) rather than a
159-
// shell command, so the command-line auto-approve analyzer cannot reliably
160-
// determine safety.
161-
const shouldShowConfirmation = !isSessionAutoApproved || context.forceConfirmationReason !== undefined;
156+
// send_to_terminal normally requires confirmation in default approvals mode
157+
// because the text may be arbitrary input (passwords, confirmations, etc.)
158+
// that the command-line auto-approve analyzer cannot assess. However, when
159+
// the text being sent was just collected via askQuestions for the same
160+
// terminal, the user already explicitly provided the answer so a second
161+
// confirmation is redundant.
162+
const isAnsweringQuestion = questionText !== undefined;
163+
const shouldShowConfirmation = (!isSessionAutoApproved && !isAnsweringQuestion) || context.forceConfirmationReason !== undefined;
162164
const confirmationMessages = shouldShowConfirmation ? {
163165
title: localize('send.confirm.title', "Send to Terminal"),
164166
message: confirmationMessage,
@@ -212,13 +214,15 @@ export class SendToTerminalTool extends Disposable implements IToolImpl {
212214

213215
/**
214216
* Searches the current session's responses for the most recent question
215-
* carousel associated with the target terminal, then matches the command
216-
* text being sent against the carousel's submitted answers to return the
217-
* specific question that this send_to_terminal call is answering.
217+
* carousel associated with the target terminal, then uses positional
218+
* matching to return the specific question that this send_to_terminal
219+
* call is answering.
218220
*
219221
* When a carousel contains multiple questions, the model calls
220-
* send_to_terminal once per answer. This method correlates each call to
221-
* the right question by matching the sent text against answer values.
222+
* send_to_terminal once per answer in order. This method counts prior
223+
* send_to_terminal invocations since the carousel to determine the
224+
* current question index, then verifies the command matches the answer
225+
* at that position.
222226
*/
223227
private _getQuestionContextForTerminal(chatSessionResource: URI | undefined, args: ISendToTerminalInputParams): string | undefined {
224228
if (!chatSessionResource) {
@@ -245,40 +249,58 @@ export class SendToTerminalTool extends Disposable implements IToolImpl {
245249
continue;
246250
}
247251
const parts = response.response.value;
252+
253+
// First, find the carousel for this terminal (searching backwards)
254+
let carouselIndex = -1;
255+
let carousel: IChatQuestionCarousel | undefined;
248256
for (let j = parts.length - 1; j >= 0; j--) {
249257
const part = parts[j];
250258
if (part.kind === 'questionCarousel') {
251-
const carousel = part as IChatQuestionCarousel;
252-
if (!carousel.terminalId || carousel.questions.length === 0) {
259+
const candidate = part as IChatQuestionCarousel;
260+
if (!candidate.terminalId || candidate.questions.length === 0) {
253261
continue;
254262
}
255-
// Match by execution UUID or by resolving the carousel's UUID to an instance ID
256-
const matchesById = !!args.id && carousel.terminalId === args.id;
263+
const matchesById = !!args.id && candidate.terminalId === args.id;
257264
const matchesByInstanceId = args.terminalId !== undefined &&
258-
RunInTerminalTool.getExecution(carousel.terminalId)?.instance.instanceId === args.terminalId;
259-
if (!matchesById && !matchesByInstanceId) {
260-
continue;
265+
RunInTerminalTool.getExecution(candidate.terminalId)?.instance.instanceId === args.terminalId;
266+
if (matchesById || matchesByInstanceId) {
267+
carouselIndex = j;
268+
carousel = candidate;
269+
break;
261270
}
271+
}
272+
}
262273

263-
// If there's only one question, return it directly
264-
if (carousel.questions.length === 1) {
265-
return this._getQuestionText(carousel.questions[0]);
266-
}
274+
if (!carousel || carouselIndex === -1) {
275+
continue;
276+
}
267277

268-
// Multiple questions: match the command text against submitted answers
269-
if (carousel.data) {
270-
for (const question of carousel.questions) {
271-
const answer = carousel.data[question.id];
272-
if (this._answerMatchesCommand(answer, commandText)) {
273-
return this._getQuestionText(question);
274-
}
275-
}
276-
}
278+
// Count send_to_terminal tool invocations after the carousel to
279+
// determine which question this call corresponds to (positional).
280+
let sendCount = 0;
281+
for (let j = carouselIndex + 1; j < parts.length; j++) {
282+
if (parts[j].kind === 'toolInvocation' && (parts[j] as { toolId?: string }).toolId === TerminalToolId.SendToTerminal) {
283+
sendCount++;
284+
}
285+
}
286+
287+
const questionIndex = sendCount;
288+
if (questionIndex >= carousel.questions.length) {
289+
return undefined;
290+
}
277291

278-
// Fallback: return the first question's text
279-
return this._getQuestionText(carousel.questions[0]);
292+
const question = carousel.questions[questionIndex];
293+
294+
// Verify the command matches the answer at this position so that
295+
// unrelated send_to_terminal calls don't skip confirmation.
296+
if (carousel.data) {
297+
const answer = carousel.data[question.id];
298+
if (this._answerMatchesCommand(answer, commandText)) {
299+
return this._getQuestionText(question);
280300
}
281301
}
302+
303+
return undefined;
282304
}
283305
return undefined;
284306
}

src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ITerminalChatService, type ITerminalInstance } from '../../../../termin
1616
import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js';
1717
import type { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
1818
import { IChatService } from '../../../../chat/common/chatService/chatService.js';
19+
import { URI } from '../../../../../../base/common/uri.js';
1920

2021
suite('SendToTerminalTool', () => {
2122
const store = ensureNoDisposablesAreLeakedInTestSuite();
@@ -134,10 +135,11 @@ suite('SendToTerminalTool', () => {
134135
assert.strictEqual(mockExecution.sentTexts[0].shouldExecute, true);
135136
});
136137

137-
function createPreparationContext(id: string, command: string): IToolInvocationPreparationContext {
138+
function createPreparationContext(id: string, command: string, chatSessionResource?: URI): IToolInvocationPreparationContext {
138139
return {
139140
parameters: { id, command },
140141
toolCallId: 'test-call',
142+
chatSessionResource,
141143
} as unknown as IToolInvocationPreparationContext;
142144
}
143145

@@ -177,4 +179,176 @@ suite('SendToTerminalTool', () => {
177179
const message = prepared.invocationMessage as IMarkdownString;
178180
assert.ok(!message.value.includes('\n'), 'newlines should be collapsed to spaces');
179181
});
182+
183+
test('prepareToolInvocation skips confirmation when answering a question carousel', async () => {
184+
const sessionResource = URI.parse('chat-session://test-session');
185+
const mockSession = {
186+
getRequests: () => [{
187+
response: {
188+
response: {
189+
value: [{
190+
kind: 'questionCarousel' as const,
191+
terminalId: KNOWN_TERMINAL_ID,
192+
questions: [{ id: 'q1', title: 'package name?', message: 'package name?' }],
193+
data: { q1: 'my-package' },
194+
}]
195+
}
196+
}
197+
}],
198+
};
199+
instantiationService.stub(IChatService, 'getSession', () => mockSession);
200+
tool = store.add(instantiationService.createInstance(SendToTerminalTool));
201+
202+
const prepared = await tool.prepareToolInvocation(
203+
createPreparationContext(KNOWN_TERMINAL_ID, 'my-package', sessionResource),
204+
CancellationToken.None,
205+
);
206+
207+
assert.ok(prepared);
208+
assert.strictEqual(prepared.confirmationMessages, undefined, 'should skip confirmation when the command matches a carousel answer');
209+
});
210+
211+
test('prepareToolInvocation does not skip confirmation when the command does not match a carousel answer', async () => {
212+
const sessionResource = URI.parse('chat-session://test-session');
213+
const mockSession = {
214+
getRequests: () => [{
215+
response: {
216+
response: {
217+
value: [{
218+
kind: 'questionCarousel' as const,
219+
terminalId: KNOWN_TERMINAL_ID,
220+
questions: [{ id: 'q1', title: 'package name?', message: 'package name?' }],
221+
data: { q1: 'my-package' },
222+
}]
223+
}
224+
}
225+
}],
226+
};
227+
instantiationService.stub(IChatService, 'getSession', () => mockSession);
228+
tool = store.add(instantiationService.createInstance(SendToTerminalTool));
229+
230+
const prepared = await tool.prepareToolInvocation(
231+
createPreparationContext(KNOWN_TERMINAL_ID, 'different-package', sessionResource),
232+
CancellationToken.None,
233+
);
234+
235+
assert.ok(prepared);
236+
assert.ok(prepared.confirmationMessages, 'should require confirmation when the command does not match a carousel answer');
237+
});
238+
239+
test('prepareToolInvocation skips confirmation only for exact matches in multi-question carousels', async () => {
240+
const sessionResource = URI.parse('chat-session://test-session');
241+
const carousel = {
242+
kind: 'questionCarousel' as const,
243+
terminalId: KNOWN_TERMINAL_ID,
244+
questions: [
245+
{ id: 'q1', title: 'package name?', message: 'package name?' },
246+
{ id: 'q2', title: 'entry point?', message: 'entry point?' }
247+
],
248+
data: { q1: 'my-package', q2: 'src/index.ts' },
249+
};
250+
// Simulate one prior send_to_terminal invocation after the carousel
251+
// so that positional matching targets question[1] (entry point)
252+
const priorSendInvocation = {
253+
kind: 'toolInvocation' as const,
254+
toolId: 'send_to_terminal',
255+
};
256+
const mockSession = {
257+
getRequests: () => [{
258+
response: {
259+
response: {
260+
value: [carousel, priorSendInvocation]
261+
}
262+
}
263+
}],
264+
};
265+
instantiationService.stub(IChatService, 'getSession', () => mockSession);
266+
tool = store.add(instantiationService.createInstance(SendToTerminalTool));
267+
268+
const exactMatchPrepared = await tool.prepareToolInvocation(
269+
createPreparationContext(KNOWN_TERMINAL_ID, 'src/index.ts', sessionResource),
270+
CancellationToken.None,
271+
);
272+
273+
assert.ok(exactMatchPrepared);
274+
assert.strictEqual(exactMatchPrepared.confirmationMessages, undefined, 'should skip confirmation when the command exactly matches a carousel answer');
275+
276+
const mismatchedPrepared = await tool.prepareToolInvocation(
277+
createPreparationContext(KNOWN_TERMINAL_ID, 'src/index.js', sessionResource),
278+
CancellationToken.None,
279+
);
280+
281+
assert.ok(mismatchedPrepared);
282+
assert.ok(mismatchedPrepared.confirmationMessages, 'should require confirmation when the command does not exactly match any carousel answer');
283+
});
284+
285+
test('prepareToolInvocation uses positional matching for identical answers (all defaults)', async () => {
286+
const sessionResource = URI.parse('chat-session://test-session');
287+
const carousel = {
288+
kind: 'questionCarousel' as const,
289+
terminalId: KNOWN_TERMINAL_ID,
290+
questions: [
291+
{ id: 'q1', title: 'package name?', message: 'package name?' },
292+
{ id: 'q2', title: 'version?', message: 'version?' },
293+
{ id: 'q3', title: 'description?', message: 'description?' },
294+
],
295+
data: { q1: '', q2: '', q3: '' },
296+
};
297+
298+
// First call: no prior send_to_terminal → positional index 0 → "package name?"
299+
const mockSession0 = {
300+
getRequests: () => [{
301+
response: { response: { value: [carousel] } }
302+
}],
303+
};
304+
instantiationService.stub(IChatService, 'getSession', () => mockSession0);
305+
tool = store.add(instantiationService.createInstance(SendToTerminalTool));
306+
307+
const first = await tool.prepareToolInvocation(
308+
createPreparationContext(KNOWN_TERMINAL_ID, '', sessionResource),
309+
CancellationToken.None,
310+
);
311+
assert.ok(first);
312+
assert.strictEqual(first.confirmationMessages, undefined);
313+
const firstMsg = first.pastTenseMessage as IMarkdownString;
314+
assert.ok(firstMsg.value.includes('package'), 'first call should show package name question');
315+
316+
// Second call: one prior send_to_terminal → positional index 1 → "version?"
317+
const priorSend1 = { kind: 'toolInvocation' as const, toolId: 'send_to_terminal' };
318+
const mockSession1 = {
319+
getRequests: () => [{
320+
response: { response: { value: [carousel, priorSend1] } }
321+
}],
322+
};
323+
instantiationService.stub(IChatService, 'getSession', () => mockSession1);
324+
tool = store.add(instantiationService.createInstance(SendToTerminalTool));
325+
326+
const second = await tool.prepareToolInvocation(
327+
createPreparationContext(KNOWN_TERMINAL_ID, '', sessionResource),
328+
CancellationToken.None,
329+
);
330+
assert.ok(second);
331+
assert.strictEqual(second.confirmationMessages, undefined);
332+
const secondMsg = second.pastTenseMessage as IMarkdownString;
333+
assert.ok(secondMsg.value.includes('version'), 'second call should show version question');
334+
335+
// Third call: two prior send_to_terminal → positional index 2 → "description?"
336+
const priorSend2 = { kind: 'toolInvocation' as const, toolId: 'send_to_terminal' };
337+
const mockSession2 = {
338+
getRequests: () => [{
339+
response: { response: { value: [carousel, priorSend1, priorSend2] } }
340+
}],
341+
};
342+
instantiationService.stub(IChatService, 'getSession', () => mockSession2);
343+
tool = store.add(instantiationService.createInstance(SendToTerminalTool));
344+
345+
const third = await tool.prepareToolInvocation(
346+
createPreparationContext(KNOWN_TERMINAL_ID, '', sessionResource),
347+
CancellationToken.None,
348+
);
349+
assert.ok(third);
350+
assert.strictEqual(third.confirmationMessages, undefined);
351+
const thirdMsg = third.pastTenseMessage as IMarkdownString;
352+
assert.ok(thirdMsg.value.includes('description'), 'third call should show description question');
353+
});
180354
});

0 commit comments

Comments
 (0)