import { afterEach, describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../../test/helpers/promise.js"; import { closePluginTestAdmissions, createExecution, runPlugin, SUCCESS_RESULT, } from "./execute-plugin.test-support.js"; async function startBlockedRun(options: { timeoutMs: number; getDeadline: () => number | undefined; onChange?: (listener: () => void) => () => void; }) { const { context } = await createExecution({ timeoutMs: options.timeoutMs }); const started = createDeferred(); const run = runPlugin( context, async function* ({ abortSignal }) { if (!abortSignal) { throw new Error("Host execution did not expose its abort signal."); } started.resolve(); await new Promise((_, reject) => { abortSignal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); }); yield SUCCESS_RESULT; }, { noOutputTimeoutMs: 100, activeToolCount: () => 1, getActiveLoopbackAskUserDeadline: options.getDeadline, onActiveLoopbackAskUserDeadlineChange: options.onChange ?? (() => () => {}), }, ); await started.promise; return { run }; } afterEach(() => { closePluginTestAdmissions(); vi.useRealTimers(); }); describe("plugin-owned CLI ask_user timeout", () => { it.each([ ["uses the exact question deadline", 4_000_000, 3_610_000, "no-output-timeout"], ["keeps the earlier overall deadline authoritative", 150, 150, "overall-timeout"], ] as const)("%s", async (_name, timeoutMs, advanceMs, reason) => { vi.useFakeTimers(); const deadline = Date.now() + 3_610_000; const { run } = await startBlockedRun({ timeoutMs, getDeadline: () => deadline }); let completed = false; void run.then(() => { completed = true; }); if (reason === "no-output-timeout") { await vi.advanceTimersByTimeAsync(900_000); expect(completed).toBe(false); await vi.advanceTimersByTimeAsync(advanceMs - 900_000); } else { await vi.advanceTimersByTimeAsync(advanceMs); } await expect(run).resolves.toMatchObject({ reason, timedOut: true, noOutputTimedOut: reason === "no-output-timeout", }); }); it("does not let a short question shrink the blocked-tool watchdog floor", async () => { vi.useFakeTimers(); const deadline = Date.now() + 40_000; const { run } = await startBlockedRun({ timeoutMs: 2_000_000, getDeadline: () => deadline, }); let completed = false; void run.then(() => { completed = true; }); await vi.advanceTimersByTimeAsync(899_999); expect(completed).toBe(false); await vi.advanceTimersByTimeAsync(1); await expect(run).resolves.toMatchObject({ reason: "no-output-timeout", noOutputTimedOut: true, }); }); it("restores the remaining ordinary watchdog when ask_user clears early", async () => { vi.useFakeTimers(); let deadline: number | undefined = Date.now() + 3_610_000; const listeners = new Set<() => void>(); const { run } = await startBlockedRun({ timeoutMs: 2_000_000, getDeadline: () => deadline, onChange: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, }); await vi.advanceTimersByTimeAsync(200); deadline = undefined; listeners.forEach((listener) => listener()); await vi.advanceTimersByTimeAsync(899_799); let completed = false; void run.then(() => { completed = true; }); expect(completed).toBe(false); await vi.advanceTimersByTimeAsync(1); await expect(run).resolves.toMatchObject({ reason: "no-output-timeout", noOutputTimedOut: true, }); }); });