From 5886d8601a2fce479d0330bd6e18616662821dac Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Sat, 25 Apr 2026 09:15:53 -0400 Subject: [PATCH] Fix false-positive input detection causing premature command termination (#312392) --- .../browser/tools/monitoring/outputMonitor.ts | 59 ++++++++++++++++--- .../test/browser/outputMonitor.test.ts | 33 ++++++++++- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index 69d296e177051..c3020a95f7bb6 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -416,9 +416,13 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return this._state; } - const promptResult = detectsInputRequiredPattern(currentLastLine); + // Only fast-path on high-confidence patterns (y/n, password, (END), etc.). + // Broad patterns like bare ":" or "?" are checked later in _handleIdleState + // after the terminal has naturally gone idle, avoiding false positives on + // normal command output that happens to end with those characters. + const promptResult = detectsHighConfidenceInputPattern(currentLastLine); if (promptResult) { - this._logService.trace(`OutputMonitor: waitForIdle -> input-required pattern detected (waited=${waited}ms, lastLine=${this._formatLastLineForLog(currentTail)})`); + this._logService.trace(`OutputMonitor: waitForIdle -> high-confidence input pattern detected (waited=${waited}ms, lastLine=${this._formatLastLineForLog(currentTail)})`); this._state = OutputMonitorState.Idle; this._setupIdleInputListener(); return this._state; @@ -440,6 +444,18 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._setupIdleInputListener(); return this._state; } + + // When the terminal has been idle (no new data) but the execution is + // still reported as active (e.g. task-backed executions), check the + // broader input-required heuristics. These patterns are too noisy to + // use during active output, but once the terminal has settled they + // reliably indicate an interactive prompt like "Enter your name: ". + if (recentlyIdle && isActive === true && detectsInputRequiredPattern(currentLastLine)) { + this._logService.trace(`OutputMonitor: waitForIdle -> broad input pattern detected while active+idle (waited=${waited}ms, lastLine=${this._formatLastLineForLog(currentTail)})`); + this._state = OutputMonitorState.Idle; + this._setupIdleInputListener(); + return this._state; + } } } finally { onDataDisposable.dispose(); @@ -513,7 +529,13 @@ export function matchTerminalPromptOption(options: readonly string[], suggestedO return { option: undefined, index: -1 }; } -export function detectsInputRequiredPattern(cursorLine: string): boolean { +/** + * High-confidence patterns that reliably indicate the terminal is waiting for + * input. These are safe to use as a fast-path in `_waitForIdle` to skip normal + * idle detection, because they are specific enough to avoid false positives on + * normal command output (build logs, headers, etc.). + */ +export function detectsHighConfidenceInputPattern(cursorLine: string): boolean { return [ // PowerShell-style multi-option line (supports [?] Help and optional default suffix) ending // in whitespace. Uses [^\[]* to match each label (everything up to the next bracket), @@ -528,10 +550,6 @@ export function detectsInputRequiredPattern(cursorLine: string): boolean { // The trailing space indicates the cursor is positioned after the prompt awaiting input, as // opposed to normal command output that happens to contain "(y)" followed by a newline. /\(y\) +$/i, - // Line ends with ':' followed by at least one space. The trailing space indicates a - // waiting prompt (cursor positioned after the colon). A bare ':\n' at end of buffer is - // usually non-prompt output (e.g. a header or log line) and must not match. - /: +$/, // Prompt with parenthesized default value e.g. "package name: (test) " or "version: (1.0.0) " /:\s*\([^)]*\) +$/, // Line contains (END) which is common in pagers @@ -539,12 +557,35 @@ export function detectsInputRequiredPattern(cursorLine: string): boolean { // Password prompt (must be followed by optional colon and trailing space to indicate // an active prompt; otherwise normal output containing the word "password" would match). /password:? +$/i, + // "Press a key" or "Press any key" + /press a(?:ny)? key/i, + ].some(e => e.test(cursorLine)); +} + +/** + * Full set of input-required patterns including broader heuristics (bare `:` and + * `?` with trailing space). These may produce false positives on normal command + * output, so they should only be used **after** the terminal has been confirmed + * idle through normal polling (consecutive idle events with no data). In + * `_waitForIdle`, these are checked only when `recentlyIdle` is true (to handle + * active executions that are actually waiting for input). For the unconditional + * fast-path, use {@link detectsHighConfidenceInputPattern} instead. + */ +export function detectsInputRequiredPattern(cursorLine: string): boolean { + if (detectsHighConfidenceInputPattern(cursorLine)) { + return true; + } + return [ + // Line ends with ':' followed by at least one space. The trailing space indicates a + // waiting prompt (cursor positioned after the colon). A bare ':\n' at end of buffer is + // usually non-prompt output (e.g. a header or log line) and must not match. + // NOTE: This is a broad pattern — only use after confirming idle state via polling. + /: +$/, // Line ends with '?' followed by at least one space (optionally followed by a // parenthesized hint like "Continue? (yes/no) "). Requiring trailing space avoids // matching arbitrary command output where a line happens to end with '?'. + // NOTE: This is a broad pattern — only use after confirming idle state via polling. /\? *(?:\([a-z\s]+\))? +$/i, - // "Press a key" or "Press any key" - /press a(?:ny)? key/i, ].some(e => e.test(cursorLine)); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index cc553a021fc3f..be36b8feb7047 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import { detectsGenericPressAnyKeyPattern, detectsInputRequiredPattern, detectsNonInteractiveHelpPattern, detectsVSCodeTaskFinishMessage, getLastLine, matchTerminalPromptOption, OutputMonitor } from '../../browser/tools/monitoring/outputMonitor.js'; +import { detectsGenericPressAnyKeyPattern, detectsHighConfidenceInputPattern, detectsInputRequiredPattern, detectsNonInteractiveHelpPattern, detectsVSCodeTaskFinishMessage, getLastLine, matchTerminalPromptOption, OutputMonitor } from '../../browser/tools/monitoring/outputMonitor.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IExecution, IPollingResult, OutputMonitorState } from '../../browser/tools/monitoring/types.js'; @@ -394,6 +394,37 @@ suite('OutputMonitor', () => { }); }); + suite('detectsHighConfidenceInputPattern', () => { + test('matches y/n and PowerShell prompts', () => { + assert.strictEqual(detectsHighConfidenceInputPattern('Continue? (y/N) '), true); + assert.strictEqual(detectsHighConfidenceInputPattern('Overwrite file? [Y/n] '), true); + assert.strictEqual(detectsHighConfidenceInputPattern('[Y] Yes [N] No '), true); + assert.strictEqual(detectsHighConfidenceInputPattern('[Y] Yes [A] Yes to All [N] No [L] No to All [S] Suspend [?] Help (default is "Y"): '), true); + }); + test('matches password and press-any-key prompts', () => { + assert.strictEqual(detectsHighConfidenceInputPattern('Password: '), true); + assert.strictEqual(detectsHighConfidenceInputPattern('Press any key to continue...'), true); + }); + test('matches parenthesized defaults', () => { + assert.strictEqual(detectsHighConfidenceInputPattern('package name: (test) '), true); + assert.strictEqual(detectsHighConfidenceInputPattern('version: (1.0.0) '), true); + }); + test('matches (END) pager', () => { + assert.strictEqual(detectsHighConfidenceInputPattern('(END)'), true); + }); + test('does NOT match bare colon prompts (too broad for fast-path)', () => { + assert.strictEqual(detectsHighConfidenceInputPattern('Enter your name: '), false); + assert.strictEqual(detectsHighConfidenceInputPattern('File to overwrite: '), false); + assert.strictEqual(detectsHighConfidenceInputPattern('Building project: '), false); + assert.strictEqual(detectsHighConfidenceInputPattern('Running tests:'), false); + }); + test('does NOT match bare question prompts (too broad for fast-path)', () => { + assert.strictEqual(detectsHighConfidenceInputPattern('Continue? '), false); + assert.strictEqual(detectsHighConfidenceInputPattern('Are you sure? '), false); + assert.strictEqual(detectsHighConfidenceInputPattern('What happened?'), false); + }); + }); + suite('matchTerminalPromptOption', () => { test('matches suggested option case-insensitively', () => { assert.deepStrictEqual(matchTerminalPromptOption(['Y', 'n'], 'y'), { option: 'Y', index: 0 });