diff --git a/src/headless-events.ts b/src/headless-events.ts index 76d684424..446996257 100644 --- a/src/headless-events.ts +++ b/src/headless-events.ts @@ -117,16 +117,30 @@ export function isTerminalNotification( return TERMINAL_PREFIXES.some((prefix) => message.startsWith(prefix)); } +export function isPauseNotification(event: Record): boolean { + if (event.type !== "extension_ui_request" || event.method !== "notify") + return false; + const message = String(event.message ?? "").toLowerCase(); + return ( + message.startsWith("auto-mode paused") || + message.startsWith("step-mode paused") + ); +} + +export function isAutoResumeScheduledNotification( + event: Record, +): boolean { + if (event.type !== "extension_ui_request" || event.method !== "notify") + return false; + return /auto-resuming in \d+s/i.test(String(event.message ?? "")); +} + export function isBlockedNotification(event: Record): boolean { if (event.type !== "extension_ui_request" || event.method !== "notify") return false; const message = String(event.message ?? "").toLowerCase(); // Blocked notifications come through stopAuto as "Auto-mode stopped (Blocked: ...)" - return ( - message.includes("blocked:") || - message.startsWith("auto-mode paused") || - message.startsWith("step-mode paused") - ); + return message.includes("blocked:") || isPauseNotification(event); } export function isMilestoneReadyNotification( diff --git a/src/headless.ts b/src/headless.ts index e728bdc39..1d42b0a4a 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -30,12 +30,14 @@ import { EXIT_SUCCESS, FIRE_AND_FORGET_METHODS, IDLE_TIMEOUT_MS, - MULTI_TURN_DEADLOCK_BACKSTOP_MS, + isAutoResumeScheduledNotification, isBlockedNotification, isInteractiveHeadlessTool, isMilestoneReadyNotification, + isPauseNotification, isQuickCommand, isTerminalNotification, + MULTI_TURN_DEADLOCK_BACKSTOP_MS, mapStatusToExitCode, NEW_MILESTONE_IDLE_TIMEOUT_MS, shouldArmHeadlessIdleTimeout, @@ -554,6 +556,7 @@ async function runHeadlessOnce( let completed = false; let exitCode = 0; let milestoneReady = false; // tracks "Milestone X ready." for auto-chaining + let providerAutoResumePending = false; const recentEvents: TrackedEvent[] = []; let lastVisibleProgressAt = Date.now(); const interactiveToolCallIds = new Set(); @@ -1100,8 +1103,15 @@ async function runHeadlessOnce( // Handle extension_ui_request if (eventObj.type === "extension_ui_request" && clientStarted) { + const waitForProviderAutoResume = + providerAutoResumePending && isPauseNotification(eventObj); + + if (isAutoResumeScheduledNotification(eventObj)) { + providerAutoResumePending = true; + } + // Check for terminal notification before auto-responding - if (isBlockedNotification(eventObj)) { + if (isBlockedNotification(eventObj) && !waitForProviderAutoResume) { blocked = true; } @@ -1110,13 +1120,20 @@ async function runHeadlessOnce( milestoneReady = true; } - if (isTerminalNotification(eventObj)) { + if (isTerminalNotification(eventObj) && !waitForProviderAutoResume) { completed = true; } // Structured trace: handle unit start/end notify messages if (eventObj.method === "notify") { const message = String(eventObj.message ?? ""); + if ( + message.includes("Auto-mode resumed") || + message.includes("Step-mode resumed") || + (message.includes("[unit]") && message.includes("starting")) + ) { + providerAutoResumePending = false; + } if (traceActive) { if (message.includes("[unit]") && message.includes("starting")) { handleUnitStart(message); diff --git a/src/tests/headless-events.test.ts b/src/tests/headless-events.test.ts index 6c5746927..0ba0f77ca 100644 --- a/src/tests/headless-events.test.ts +++ b/src/tests/headless-events.test.ts @@ -194,8 +194,10 @@ import { EXIT_CANCELLED, EXIT_ERROR, EXIT_SUCCESS, + isAutoResumeScheduledNotification, isBlockedNotification, isInteractiveHeadlessTool, + isPauseNotification, isTerminalNotification, mapStatusToExitCode, shouldArmHeadlessIdleTimeout, @@ -271,6 +273,44 @@ test("isBlockedNotification: auto pause exits as blocked", () => { ); }); +test("isAutoResumeScheduledNotification detects provider auto-resume notices", () => { + assert.equal( + isAutoResumeScheduledNotification({ + type: "extension_ui_request", + method: "notify", + message: "Rate limited: rate limit exceeded. Auto-resuming in 60s...", + }), + true, + ); + assert.equal( + isAutoResumeScheduledNotification({ + type: "extension_ui_request", + method: "notify", + message: "Auto-mode paused (Escape). Type to interact.", + }), + false, + ); +}); + +test("isPauseNotification detects pause banners separately from auto-resume notices", () => { + assert.equal( + isPauseNotification({ + type: "extension_ui_request", + method: "notify", + message: "Auto-mode paused (Escape). Type to interact.", + }), + true, + ); + assert.equal( + isPauseNotification({ + type: "extension_ui_request", + method: "notify", + message: "Rate limited: rate limit exceeded. Auto-resuming in 60s...", + }), + false, + ); +}); + test("shouldArmHeadlessIdleTimeout: arms after tool calls when no interactive tool is in flight", () => { assert.equal(shouldArmHeadlessIdleTimeout(1, 0), true); assert.equal(shouldArmHeadlessIdleTimeout(3, 0), true);