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(); });