From 4f4f852ad6842da7c3410ff1276a6cfba8e6d1a0 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 9 Jul 2026 23:14:17 +0000 Subject: [PATCH 1/5] fix(coding-agent): restored ask tool timeout Ensured ask tool timeouts abort stalled UI selectors and return the recommended option instead of hanging. Added regression coverage for selectors that never settle. Fixes #4995 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/tools/ask.ts | 45 ++++++--- .../coding-agent/test/ask-timeout.test.ts | 91 +++++++++++++++++++ 3 files changed, 128 insertions(+), 12 deletions(-) create mode 100644 packages/coding-agent/test/ask-timeout.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6c5d526e2..ff2122782 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the ask tool timeout so it auto-selects the recommended option even when the UI selector does not settle on its own. ([#4995](https://github.com/can1357/oh-my-pi/issues/4995)) + ## [16.3.15] - 2026-07-09 ### Changed diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 42de7f930..c58ef975a 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -432,10 +432,16 @@ async function askSingleQuestion( const helpText = navigation ? "up/down navigate enter select ←/→ question esc cancel" : "up/down navigate enter select esc cancel"; + const timeoutController = typeof timeout === "number" && timeout > 0 ? new AbortController() : undefined; + const dialogSignal = + signal && timeoutController + ? AbortSignal.any([signal, timeoutController.signal]) + : (timeoutController?.signal ?? signal); + let timeoutId: NodeJS.Timeout | undefined; const dialogOptions = { initialIndex, timeout, - signal, + signal: dialogSignal, outline: true, onTimeout, helpText, @@ -454,18 +460,33 @@ async function askSingleQuestion( : undefined, }; const startMs = Date.now(); - const choice = signal - ? await untilAborted(signal, () => ui.select(prompt, optionsToShow, dialogOptions)) - : await ui.select(prompt, optionsToShow, dialogOptions); - if (!timeoutTriggered && choice === undefined && typeof timeout === "number") { - // Fallback for UI surfaces that enforce `timeout` without invoking - // `onTimeout`: their auto-cancel resolves right at the deadline. A - // cancel arriving well past the deadline is a deliberate user Esc on - // a surface that kept the dialog open — keep treating it as a cancel. - const elapsed = Date.now() - startMs; - timeoutTriggered = elapsed >= timeout && elapsed <= timeout + TIMEOUT_DETECTION_TOLERANCE_MS; + if (timeoutController && typeof timeout === "number") { + timeoutId = setTimeout(() => { + timeoutTriggered = true; + timeoutController.abort(); + }, timeout); + } + try { + const choice = dialogSignal + ? await untilAborted(dialogSignal, () => ui.select(prompt, optionsToShow, dialogOptions)) + : await ui.select(prompt, optionsToShow, dialogOptions); + if (!timeoutTriggered && choice === undefined && typeof timeout === "number") { + // Fallback for UI surfaces that enforce `timeout` without invoking + // `onTimeout`: their auto-cancel resolves right at the deadline. A + // cancel arriving well past the deadline is a deliberate user Esc on + // a surface that kept the dialog open — keep treating it as a cancel. + const elapsed = Date.now() - startMs; + timeoutTriggered = elapsed >= timeout && elapsed <= timeout + TIMEOUT_DETECTION_TOLERANCE_MS; + } + return { choice, timedOut: timeoutTriggered, navigation: navigationAction }; + } catch (error) { + if (timeoutTriggered && error instanceof Error && error.name === "AbortError") { + return { choice: undefined, timedOut: true, navigation: navigationAction }; + } + throw error; + } finally { + clearTimeout(timeoutId); } - return { choice, timedOut: timeoutTriggered, navigation: navigationAction }; }; const promptForCustomInput = async ( diff --git a/packages/coding-agent/test/ask-timeout.test.ts b/packages/coding-agent/test/ask-timeout.test.ts new file mode 100644 index 000000000..ebbf14f67 --- /dev/null +++ b/packages/coding-agent/test/ask-timeout.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; +import type { AgentToolContext, AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import { getThemeByName, setThemeInstance } from "../src/modes/theme/theme"; +import type { ToolSession } from "../src/tools"; +import { AskTool, type AskToolDetails } from "../src/tools/ask"; + +type AskExecutionResult = AgentToolResult; + +async function drainMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +function createAskTool(): AskTool { + return new AskTool({ + hasUI: true, + settings: { + get(key: string): unknown { + if (key === "ask.timeout") return 0.01; + if (key === "ask.notify") return "off"; + if (key === "speech.enabled") return false; + return undefined; + }, + }, + getPlanModeState: () => ({ enabled: false }), + } as unknown as ToolSession); +} + +describe("AskTool timeout", () => { + beforeAll(async () => { + const loaded = await getThemeByName("dark"); + if (!loaded) throw new Error("theme unavailable"); + setThemeInstance(loaded); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("auto-selects the recommended option when the selector does not settle", async () => { + vi.useFakeTimers(); + const select = vi.fn(() => new Promise(() => {})); + const abort = vi.fn(); + const context = { + hasUI: true, + ui: { + select, + editor: vi.fn(), + }, + abort, + } as unknown as AgentToolContext; + let result: AskExecutionResult | undefined; + let rejection: unknown; + + void createAskTool() + .execute( + "ask-timeout", + { + questions: [ + { + id: "db", + question: "Which database?", + options: [{ label: "SQLite" }, { label: "Postgres" }], + recommended: 1, + }, + ], + }, + undefined, + undefined, + context, + ) + .then( + value => { + result = value; + }, + error => { + rejection = error; + }, + ); + + await drainMicrotasks(); + vi.advanceTimersByTime(10); + await drainMicrotasks(); + + expect(rejection).toBeUndefined(); + expect(result?.details?.selectedOptions).toEqual(["Postgres"]); + expect(result?.details?.timedOut).toBe(true); + expect(abort).not.toHaveBeenCalled(); + }); +}); From facfb3c35aa6cf2dd5a33d74473b5d8d587e78bc Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 9 Jul 2026 23:37:50 +0000 Subject: [PATCH 2/5] fix(coding-agent): synced ask fallback timeout resets Reset the ask tool fallback timeout whenever the interactive selector resets its UI countdown, preventing late keypresses from falling back to the original recommended option. Refs #4995 --- .../src/extensibility/extensions/types.ts | 2 + .../src/modes/components/hook-selector.ts | 9 +- .../controllers/extension-ui-controller.ts | 1 + packages/coding-agent/src/tools/ask.ts | 24 +++-- .../coding-agent/test/ask-timeout.test.ts | 89 ++++++++++++++++++- 5 files changed, 114 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index cfff0ed01..59b67e3b9 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -125,6 +125,8 @@ export interface ExtensionUIDialogOptions { timeout?: number; /** Invoked when the UI times out while waiting for a selection/input */ onTimeout?: () => void; + /** Invoked when user input resets a UI-managed timeout countdown */ + onTimeoutReset?: () => void; /** Initial cursor position for select dialogs (0-indexed) */ initialIndex?: number; /** Render an outlined list for select dialogs */ diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index 99ea1ea1e..f5e5570b3 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -61,6 +61,7 @@ export interface HookSelectorOptions { tui?: TUI; timeout?: number; onTimeout?: () => void; + onTimeoutReset?: () => void; initialIndex?: number; outline?: boolean; maxVisible?: number; @@ -178,6 +179,7 @@ export class HookSelectorComponent extends Container { #onLeftCallback: (() => void) | undefined; #onRightCallback: (() => void) | undefined; #onExternalEditorCallback: (() => void) | undefined; + #onTimeoutResetCallback: (() => void) | undefined; #slider: HookSelectorSlider | undefined; #sliderIndex: number = 0; #sliderComponent: Text | undefined; @@ -213,6 +215,7 @@ export class HookSelectorComponent extends Container { this.#onLeftCallback = opts?.onLeft; this.#onRightCallback = opts?.onRight; this.#onExternalEditorCallback = opts?.onExternalEditor; + this.#onTimeoutResetCallback = opts?.onTimeoutReset; if (opts?.slider && opts.slider.segments.length > 0) { this.#slider = opts.slider; this.#sliderIndex = Math.max(0, Math.min(opts.slider.index, opts.slider.segments.length - 1)); @@ -633,8 +636,10 @@ export class HookSelectorComponent extends Container { } handleInput(keyData: string): void { - // Reset countdown on any interaction - this.#countdown?.reset(); + if (this.#countdown) { + this.#countdown.reset(); + this.#onTimeoutResetCallback?.(); + } if (matchesSelectCancel(keyData)) { this.#onCancelCallback(); diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 684642b12..ef27b3644 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -624,6 +624,7 @@ export class ExtensionUiController { initialIndex: dialogOptions?.initialIndex, timeout: dialogOptions?.timeout, onTimeout: dialogOptions?.onTimeout, + onTimeoutReset: dialogOptions?.onTimeoutReset, tui: this.ctx.ui, outline: dialogOptions?.outline, disabledIndices: dialogOptions?.disabledIndices, diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index c58ef975a..15fdc8994 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -389,6 +389,7 @@ interface UIContext { signal?: AbortSignal; outline?: boolean; onTimeout?: () => void; + onTimeoutReset?: () => void; onLeft?: () => void; onRight?: () => void; helpText?: string; @@ -432,18 +433,29 @@ async function askSingleQuestion( const helpText = navigation ? "up/down navigate enter select ←/→ question esc cancel" : "up/down navigate enter select esc cancel"; - const timeoutController = typeof timeout === "number" && timeout > 0 ? new AbortController() : undefined; + const timeoutMs = typeof timeout === "number" && timeout > 0 ? timeout : undefined; + const timeoutController = timeoutMs === undefined ? undefined : new AbortController(); const dialogSignal = signal && timeoutController ? AbortSignal.any([signal, timeoutController.signal]) : (timeoutController?.signal ?? signal); let timeoutId: NodeJS.Timeout | undefined; + let timeoutStartedMs = Date.now(); + const armFallbackTimeout = (durationMs: number) => { + clearTimeout(timeoutId); + timeoutStartedMs = Date.now(); + timeoutId = setTimeout(() => { + timeoutTriggered = true; + timeoutController?.abort(); + }, durationMs); + }; const dialogOptions = { initialIndex, timeout, signal: dialogSignal, outline: true, onTimeout, + onTimeoutReset: timeoutMs === undefined ? undefined : () => armFallbackTimeout(timeoutMs), helpText, selectionMarker: marker?.selectionMarker, checkedIndices: marker?.checkedIndices, @@ -459,12 +471,8 @@ async function askSingleQuestion( } : undefined, }; - const startMs = Date.now(); - if (timeoutController && typeof timeout === "number") { - timeoutId = setTimeout(() => { - timeoutTriggered = true; - timeoutController.abort(); - }, timeout); + if (timeoutMs !== undefined) { + armFallbackTimeout(timeoutMs); } try { const choice = dialogSignal @@ -475,7 +483,7 @@ async function askSingleQuestion( // `onTimeout`: their auto-cancel resolves right at the deadline. A // cancel arriving well past the deadline is a deliberate user Esc on // a surface that kept the dialog open — keep treating it as a cancel. - const elapsed = Date.now() - startMs; + const elapsed = Date.now() - timeoutStartedMs; timeoutTriggered = elapsed >= timeout && elapsed <= timeout + TIMEOUT_DETECTION_TOLERANCE_MS; } return { choice, timedOut: timeoutTriggered, navigation: navigationAction }; diff --git a/packages/coding-agent/test/ask-timeout.test.ts b/packages/coding-agent/test/ask-timeout.test.ts index ebbf14f67..34a153207 100644 --- a/packages/coding-agent/test/ask-timeout.test.ts +++ b/packages/coding-agent/test/ask-timeout.test.ts @@ -1,10 +1,18 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test"; import type { AgentToolContext, AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import type { TUI } from "@oh-my-pi/pi-tui"; +import type { ExtensionUIDialogOptions, ExtensionUISelectItem } from "../src/extensibility/extensions"; +import { HookSelectorComponent } from "../src/modes/components/hook-selector"; import { getThemeByName, setThemeInstance } from "../src/modes/theme/theme"; import type { ToolSession } from "../src/tools"; import { AskTool, type AskToolDetails } from "../src/tools/ask"; type AskExecutionResult = AgentToolResult; +type AskSelect = ( + title: string, + options: ExtensionUISelectItem[], + dialogOptions?: ExtensionUIDialogOptions, +) => Promise; async function drainMicrotasks(): Promise { await Promise.resolve(); @@ -40,7 +48,7 @@ describe("AskTool timeout", () => { it("auto-selects the recommended option when the selector does not settle", async () => { vi.useFakeTimers(); - const select = vi.fn(() => new Promise(() => {})); + const select = vi.fn(() => new Promise(() => {})); const abort = vi.fn(); const context = { hasUI: true, @@ -88,4 +96,83 @@ describe("AskTool timeout", () => { expect(result?.details?.timedOut).toBe(true); expect(abort).not.toHaveBeenCalled(); }); + + it("honors selector timeout resets before using the fallback timeout", async () => { + vi.useFakeTimers(); + let resetTimeout: (() => void) | undefined; + const select = vi.fn((_title, _options, dialogOptions) => { + resetTimeout = dialogOptions?.onTimeoutReset; + return new Promise(() => {}); + }); + const abort = vi.fn(); + const context = { + hasUI: true, + ui: { + select, + editor: vi.fn(), + }, + abort, + } as unknown as AgentToolContext; + let result: AskExecutionResult | undefined; + let rejection: unknown; + + void createAskTool() + .execute( + "ask-timeout", + { + questions: [ + { + id: "db", + question: "Which database?", + options: [{ label: "SQLite" }, { label: "Postgres" }], + recommended: 1, + }, + ], + }, + undefined, + undefined, + context, + ) + .then( + value => { + result = value; + }, + error => { + rejection = error; + }, + ); + + await drainMicrotasks(); + expect(resetTimeout).toBeDefined(); + + vi.advanceTimersByTime(9); + resetTimeout?.(); + vi.advanceTimersByTime(9); + await drainMicrotasks(); + + expect(result).toBeUndefined(); + + vi.advanceTimersByTime(1); + await drainMicrotasks(); + + expect(rejection).toBeUndefined(); + expect(result?.details?.selectedOptions).toEqual(["Postgres"]); + expect(result?.details?.timedOut).toBe(true); + expect(abort).not.toHaveBeenCalled(); + }); + + it("notifies callers when selector input resets the UI countdown", () => { + vi.useFakeTimers(); + const onTimeoutReset = vi.fn(); + const selector = new HookSelectorComponent("Pick one", ["SQLite", "Postgres"], vi.fn(), vi.fn(), { + timeout: 10, + tui: { requestRender: vi.fn() } as unknown as TUI, + onTimeoutReset, + }); + + selector.handleInput("j"); + + expect(onTimeoutReset).toHaveBeenCalledTimes(1); + selector.dispose(); + }); }); From fcf389ae72c6267887c4e486bccbf844fff517bb Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 10 Jul 2026 02:49:33 +0000 Subject: [PATCH 3/5] fix(coding-agent): deferred ask timeout until display Started the ask tool fallback timeout from the selector presentation callback so queued dialogs do not consume the user's response window. Refs #4995 --- .../src/extensibility/extensions/types.ts | 2 + .../src/modes/components/hook-selector.ts | 2 + .../controllers/extension-ui-controller.ts | 1 + packages/coding-agent/src/tools/ask.ts | 5 +- .../coding-agent/test/ask-timeout.test.ts | 74 ++++++++++++++++++- 5 files changed, 79 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 59b67e3b9..aa2f0764e 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -125,6 +125,8 @@ export interface ExtensionUIDialogOptions { timeout?: number; /** Invoked when the UI times out while waiting for a selection/input */ onTimeout?: () => void; + /** Invoked when the UI-managed timeout countdown starts */ + onTimeoutStart?: () => void; /** Invoked when user input resets a UI-managed timeout countdown */ onTimeoutReset?: () => void; /** Initial cursor position for select dialogs (0-indexed) */ diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index f5e5570b3..3165166ae 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -61,6 +61,7 @@ export interface HookSelectorOptions { tui?: TUI; timeout?: number; onTimeout?: () => void; + onTimeoutStart?: () => void; onTimeoutReset?: () => void; initialIndex?: number; outline?: boolean; @@ -235,6 +236,7 @@ export class HookSelectorComponent extends Container { } if (opts?.timeout && opts.timeout > 0 && opts.tui) { + opts.onTimeoutStart?.(); this.#countdown = new CountdownTimer( opts.timeout, opts.tui, diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index ef27b3644..f21932721 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -624,6 +624,7 @@ export class ExtensionUiController { initialIndex: dialogOptions?.initialIndex, timeout: dialogOptions?.timeout, onTimeout: dialogOptions?.onTimeout, + onTimeoutStart: dialogOptions?.onTimeoutStart, onTimeoutReset: dialogOptions?.onTimeoutReset, tui: this.ctx.ui, outline: dialogOptions?.outline, diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 15fdc8994..105284b38 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -389,6 +389,7 @@ interface UIContext { signal?: AbortSignal; outline?: boolean; onTimeout?: () => void; + onTimeoutStart?: () => void; onTimeoutReset?: () => void; onLeft?: () => void; onRight?: () => void; @@ -455,6 +456,7 @@ async function askSingleQuestion( signal: dialogSignal, outline: true, onTimeout, + onTimeoutStart: timeoutMs === undefined ? undefined : () => armFallbackTimeout(timeoutMs), onTimeoutReset: timeoutMs === undefined ? undefined : () => armFallbackTimeout(timeoutMs), helpText, selectionMarker: marker?.selectionMarker, @@ -471,9 +473,6 @@ async function askSingleQuestion( } : undefined, }; - if (timeoutMs !== undefined) { - armFallbackTimeout(timeoutMs); - } try { const choice = dialogSignal ? await untilAborted(dialogSignal, () => ui.select(prompt, optionsToShow, dialogOptions)) diff --git a/packages/coding-agent/test/ask-timeout.test.ts b/packages/coding-agent/test/ask-timeout.test.ts index 34a153207..be8e2ba8b 100644 --- a/packages/coding-agent/test/ask-timeout.test.ts +++ b/packages/coding-agent/test/ask-timeout.test.ts @@ -48,7 +48,10 @@ describe("AskTool timeout", () => { it("auto-selects the recommended option when the selector does not settle", async () => { vi.useFakeTimers(); - const select = vi.fn(() => new Promise(() => {})); + const select = vi.fn((_title, _options, dialogOptions) => { + dialogOptions?.onTimeoutStart?.(); + return new Promise(() => {}); + }); const abort = vi.fn(); const context = { hasUI: true, @@ -101,6 +104,7 @@ describe("AskTool timeout", () => { vi.useFakeTimers(); let resetTimeout: (() => void) | undefined; const select = vi.fn((_title, _options, dialogOptions) => { + dialogOptions?.onTimeoutStart?.(); resetTimeout = dialogOptions?.onTimeoutReset; return new Promise(() => {}); }); @@ -161,17 +165,83 @@ describe("AskTool timeout", () => { expect(abort).not.toHaveBeenCalled(); }); - it("notifies callers when selector input resets the UI countdown", () => { + it("does not run the fallback timeout while the selector is queued", async () => { vi.useFakeTimers(); + let startTimeout: (() => void) | undefined; + const select = vi.fn((_title, _options, dialogOptions) => { + startTimeout = dialogOptions?.onTimeoutStart; + return new Promise(() => {}); + }); + const abort = vi.fn(); + const context = { + hasUI: true, + ui: { + select, + editor: vi.fn(), + }, + abort, + } as unknown as AgentToolContext; + let result: AskExecutionResult | undefined; + let rejection: unknown; + + void createAskTool() + .execute( + "ask-timeout", + { + questions: [ + { + id: "db", + question: "Which database?", + options: [{ label: "SQLite" }, { label: "Postgres" }], + recommended: 1, + }, + ], + }, + undefined, + undefined, + context, + ) + .then( + value => { + result = value; + }, + error => { + rejection = error; + }, + ); + + await drainMicrotasks(); + expect(startTimeout).toBeDefined(); + + vi.advanceTimersByTime(10); + await drainMicrotasks(); + + expect(result).toBeUndefined(); + + startTimeout?.(); + vi.advanceTimersByTime(10); + await drainMicrotasks(); + + expect(rejection).toBeUndefined(); + expect(result?.details?.selectedOptions).toEqual(["Postgres"]); + expect(result?.details?.timedOut).toBe(true); + expect(abort).not.toHaveBeenCalled(); + }); + + it("notifies callers when the selector countdown starts and resets", () => { + vi.useFakeTimers(); + const onTimeoutStart = vi.fn(); const onTimeoutReset = vi.fn(); const selector = new HookSelectorComponent("Pick one", ["SQLite", "Postgres"], vi.fn(), vi.fn(), { timeout: 10, tui: { requestRender: vi.fn() } as unknown as TUI, + onTimeoutStart, onTimeoutReset, }); selector.handleInput("j"); + expect(onTimeoutStart).toHaveBeenCalledTimes(1); expect(onTimeoutReset).toHaveBeenCalledTimes(1); selector.dispose(); }); From 095353ed43f64daa85a8a2b0d3bb7052ad905035 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 10 Jul 2026 03:05:54 +0000 Subject: [PATCH 4/5] fix(coding-agent): preserved multi-question timeout defaults Applied the timeout auto-selection before multi-question single-choice prompts auto-advance, and replaced pending test promises with Promise.withResolvers(). Refs #4995 --- packages/coding-agent/src/tools/ask.ts | 3 + .../coding-agent/test/ask-timeout.test.ts | 75 ++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 105284b38..26b9c9d67 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -642,6 +642,9 @@ async function askSingleQuestion( customInput = undefined; break; } + if (timedOut && selectedOptions.length === 0 && customInput === undefined) { + selectedOptions = getAutoSelectionOnTimeout(questionOptions, recommended); + } if (navigation?.allowForward) { return { selectedOptions, customInput, timedOut, navigation: "forward" }; } diff --git a/packages/coding-agent/test/ask-timeout.test.ts b/packages/coding-agent/test/ask-timeout.test.ts index be8e2ba8b..bd2bf36c7 100644 --- a/packages/coding-agent/test/ask-timeout.test.ts +++ b/packages/coding-agent/test/ask-timeout.test.ts @@ -50,7 +50,7 @@ describe("AskTool timeout", () => { vi.useFakeTimers(); const select = vi.fn((_title, _options, dialogOptions) => { dialogOptions?.onTimeoutStart?.(); - return new Promise(() => {}); + return Promise.withResolvers().promise; }); const abort = vi.fn(); const context = { @@ -106,7 +106,7 @@ describe("AskTool timeout", () => { const select = vi.fn((_title, _options, dialogOptions) => { dialogOptions?.onTimeoutStart?.(); resetTimeout = dialogOptions?.onTimeoutReset; - return new Promise(() => {}); + return Promise.withResolvers().promise; }); const abort = vi.fn(); const context = { @@ -170,7 +170,7 @@ describe("AskTool timeout", () => { let startTimeout: (() => void) | undefined; const select = vi.fn((_title, _options, dialogOptions) => { startTimeout = dialogOptions?.onTimeoutStart; - return new Promise(() => {}); + return Promise.withResolvers().promise; }); const abort = vi.fn(); const context = { @@ -228,6 +228,75 @@ describe("AskTool timeout", () => { expect(abort).not.toHaveBeenCalled(); }); + it("auto-selects timed-out single-choice questions before advancing multi-question asks", async () => { + vi.useFakeTimers(); + let callCount = 0; + const select = vi.fn((_title, _options, dialogOptions) => { + callCount += 1; + if (callCount === 1) { + dialogOptions?.onTimeoutStart?.(); + return Promise.withResolvers().promise; + } + return Promise.resolve("OAuth"); + }); + const abort = vi.fn(); + const context = { + hasUI: true, + ui: { + select, + editor: vi.fn(), + }, + abort, + } as unknown as AgentToolContext; + let result: AskExecutionResult | undefined; + let rejection: unknown; + + void createAskTool() + .execute( + "ask-timeout", + { + questions: [ + { + id: "db", + question: "Which database?", + options: [{ label: "SQLite" }, { label: "Postgres" }], + recommended: 1, + }, + { + id: "auth", + question: "Which auth?", + options: [{ label: "JWT" }, { label: "OAuth" }], + recommended: 0, + }, + ], + }, + undefined, + undefined, + context, + ) + .then( + value => { + result = value; + }, + error => { + rejection = error; + }, + ); + + await drainMicrotasks(); + vi.advanceTimersByTime(10); + await drainMicrotasks(); + await drainMicrotasks(); + + expect(rejection).toBeUndefined(); + expect(select).toHaveBeenCalledTimes(2); + expect(result?.details?.results?.[0]?.selectedOptions).toEqual(["Postgres"]); + expect(result?.details?.results?.[0]?.timedOut).toBe(true); + expect(result?.details?.results?.[1]?.selectedOptions).toEqual(["OAuth"]); + expect(result?.details?.results?.[1]?.timedOut).toBeUndefined(); + expect(abort).not.toHaveBeenCalled(); + }); + it("notifies callers when the selector countdown starts and resets", () => { vi.useFakeTimers(); const onTimeoutStart = vi.fn(); From bc9f4c4be64665f6c51f5353e3718969ab4be64f Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 10 Jul 2026 04:13:18 +0000 Subject: [PATCH 5/5] fix(coding-agent): armed ask timeout for legacy ui Added an explicit timeout presentation capability so interactive queued dialogs defer the fallback while older UI implementations still get an immediate tool-owned timeout. Refs #4995 --- .../src/extensibility/extensions/types.ts | 2 ++ .../modes/controllers/extension-ui-controller.ts | 1 + packages/coding-agent/src/tools/ask.ts | 13 ++++++++++--- packages/coding-agent/test/ask-timeout.test.ts | 6 ++---- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index aa2f0764e..e3bb89739 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -180,6 +180,8 @@ export type AutocompleteProviderFactory = (current: AutocompleteProvider) => Aut // and may be invoked from event handlers that have already taken the agent // loop's lock — hooks intentionally cannot. export interface ExtensionUIContext { + /** True when selector timeouts start only after the dialog is presented. */ + timeoutStartsOnPresentation?: boolean; /** Show a selector and return the selected label, even when an option also includes a description. */ select( title: string, diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index f21932721..99a840721 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -61,6 +61,7 @@ export class ExtensionUiController { async initHooksAndCustomTools(): Promise { // Create and set hook & tool UI context const uiContext: ExtensionUIContext = { + timeoutStartsOnPresentation: true, select: (title, options, dialogOptions) => this.showCollabAwareSelector(title, options, dialogOptions), confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message), input: (title, placeholder, dialogOptions) => this.showHookInput(title, placeholder, dialogOptions), diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 26b9c9d67..efaf65db5 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -380,6 +380,7 @@ interface AskSingleQuestionOptions { } interface UIContext { + timeoutStartsOnPresentation?: boolean; select( prompt: string, options: ExtensionUISelectItem[], @@ -474,9 +475,14 @@ async function askSingleQuestion( : undefined, }; try { - const choice = dialogSignal - ? await untilAborted(dialogSignal, () => ui.select(prompt, optionsToShow, dialogOptions)) - : await ui.select(prompt, optionsToShow, dialogOptions); + const runSelect = () => { + const selection = ui.select(prompt, optionsToShow, dialogOptions); + if (timeoutMs !== undefined && !ui.timeoutStartsOnPresentation) { + armFallbackTimeout(timeoutMs); + } + return selection; + }; + const choice = dialogSignal ? await untilAborted(dialogSignal, runSelect) : await runSelect(); if (!timeoutTriggered && choice === undefined && typeof timeout === "number") { // Fallback for UI surfaces that enforce `timeout` without invoking // `onTimeout`: their auto-cancel resolves right at the deadline. A @@ -774,6 +780,7 @@ export class AskTool implements AgentTool { const extensionUi = context.ui; const ui: UIContext = { + timeoutStartsOnPresentation: extensionUi.timeoutStartsOnPresentation, select: (prompt, options, dialogOptions) => extensionUi.select(prompt, options, dialogOptions), editor: (title, prefill, dialogOptions, editorOptions) => extensionUi.editor(title, prefill, dialogOptions, editorOptions), diff --git a/packages/coding-agent/test/ask-timeout.test.ts b/packages/coding-agent/test/ask-timeout.test.ts index bd2bf36c7..0caaf8bd6 100644 --- a/packages/coding-agent/test/ask-timeout.test.ts +++ b/packages/coding-agent/test/ask-timeout.test.ts @@ -48,10 +48,7 @@ describe("AskTool timeout", () => { it("auto-selects the recommended option when the selector does not settle", async () => { vi.useFakeTimers(); - const select = vi.fn((_title, _options, dialogOptions) => { - dialogOptions?.onTimeoutStart?.(); - return Promise.withResolvers().promise; - }); + const select = vi.fn(() => Promise.withResolvers().promise); const abort = vi.fn(); const context = { hasUI: true, @@ -176,6 +173,7 @@ describe("AskTool timeout", () => { const context = { hasUI: true, ui: { + timeoutStartsOnPresentation: true, select, editor: vi.fn(), },