diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ad20be261..51e3d2356 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `tab.scroll()` timing out after a queued wheel event waits too long for a busy renderer's acknowledgement ([#5905](https://github.com/can1357/oh-my-pi/issues/5905)). + ## [17.0.3] - 2026-07-17 ### Changed diff --git a/packages/coding-agent/src/tools/browser/tab-worker.ts b/packages/coding-agent/src/tools/browser/tab-worker.ts index 55bb31eb2..b7229bb57 100644 --- a/packages/coding-agent/src/tools/browser/tab-worker.ts +++ b/packages/coding-agent/src/tools/browser/tab-worker.ts @@ -144,6 +144,8 @@ interface OpenDialogInfo { */ const QUICK_OP_TIMEOUT_MS = 20_000; const ACTION_OP_TIMEOUT_MS = 8_000; +/** Maximum wait for a renderer acknowledgement after a wheel event is queued. */ +const SCROLL_ACK_TIMEOUT_MS = 2_000; /** Headroom subtracted from the cell budget so a per-op deadline fires before it. */ const OP_DEADLINE_SLACK_MS = CELL_BUDGET_SLACK_MS; /** @@ -175,6 +177,14 @@ export function resolveOpTimeouts(cellTimeoutMs: number): OpTimeouts { }; } +/** Queue a wheel event without treating a delayed renderer acknowledgement as dispatch failure. */ +export async function dispatchScroll( + dispatch: () => Promise, + ackTimeoutMs = SCROLL_ACK_TIMEOUT_MS, +): Promise { + await Promise.race([dispatch(), Bun.sleep(ackTimeoutMs)]); +} + /** * Effective timeout for a wait helper (`waitFor*`). A positive explicit `{ timeout }` is * honored but clamped to the cell budget so it still fails fast + named; raising the tool @@ -1216,7 +1226,9 @@ export class WorkerCore { await untilAborted(sig, () => page.keyboard.press(key)); }), scroll: (deltaX, deltaY) => - op("tab.scroll()", actionOpMs, sig => untilAborted(sig, () => page.mouse.wheel({ deltaX, deltaY }))), + op("tab.scroll()", actionOpMs, sig => + untilAborted(sig, () => dispatchScroll(() => page.mouse.wheel({ deltaX, deltaY }))), + ), drag: (from, to) => op("tab.drag()", actionOpMs, sig => this.#drag(from, to, sig)), waitFor: (selector, opts) => { const w = waitMs(opts?.timeout); diff --git a/packages/coding-agent/test/tools/browser-tab-timeouts.test.ts b/packages/coding-agent/test/tools/browser-tab-timeouts.test.ts index ed66b8fd9..6f023146c 100644 --- a/packages/coding-agent/test/tools/browser-tab-timeouts.test.ts +++ b/packages/coding-agent/test/tools/browser-tab-timeouts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { resolvePredicateTimeout } from "@oh-my-pi/pi-coding-agent/tools/browser/run-cancellation"; import { + dispatchScroll, normalizeSelector, resolveOpTimeouts, resolveWaitTimeout, @@ -42,6 +43,20 @@ describe("browser per-op fail-fast ceilings", () => { }); }); +describe("browser scroll acknowledgement", () => { + it("returns after the acknowledgement deadline while the renderer remains stalled", async () => { + const acknowledgement = Promise.withResolvers(); + + await expect(dispatchScroll(() => acknowledgement.promise, 1)).resolves.toBeUndefined(); + }); + + it("preserves wheel dispatch failures received before the acknowledgement deadline", async () => { + await expect(dispatchScroll(() => Promise.reject(new Error("target closed")), 100)).rejects.toThrow( + "target closed", + ); + }); +}); + describe("browser wait-helper timeout resolution", () => { it("defaults a wait to the action ceiling when no explicit timeout is given", () => { const cell = 30_000;