diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index c25291eb2..2c58df630 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -32,6 +32,40 @@ function configuredExaSearchDelayMs(): number { } } +function rejectWithAbortReason(reject: (reason?: unknown) => void, signal: AbortSignal): void { + try { + signal.throwIfAborted(); + reject(new DOMException("The operation was aborted.", "AbortError")); + } catch (error) { + reject(error); + } +} + +function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise { + if (ms <= 0) return Promise.resolve(); + signal?.throwIfAborted(); + const { promise, resolve, reject } = Promise.withResolvers(); + let timer: NodeJS.Timeout | undefined; + const cleanup = (): void => { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + signal?.removeEventListener("abort", onAbort); + }; + const onAbort = (): void => { + cleanup(); + if (signal) rejectWithAbortReason(reject, signal); + }; + timer = setTimeout(() => { + cleanup(); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); + return promise; +} + async function waitForExaSearchSlot(signal: AbortSignal | undefined): Promise { const delayMs = configuredExaSearchDelayMs(); if (delayMs <= 0) return; @@ -41,7 +75,7 @@ async function waitForExaSearchSlot(signal: AbortSignal | undefined): Promise 0) { - await Bun.sleep(waitMs); + await abortableSleep(waitMs, signal); } signal?.throwIfAborted(); nextExaSearchRequestAt = Date.now() + delayMs; diff --git a/packages/coding-agent/test/tools/web-search-exa.test.ts b/packages/coding-agent/test/tools/web-search-exa.test.ts index 1148cb280..6df9fba7f 100644 --- a/packages/coding-agent/test/tools/web-search-exa.test.ts +++ b/packages/coding-agent/test/tools/web-search-exa.test.ts @@ -322,6 +322,33 @@ describe("searchExa", () => { expect(requestTimes[1] - requestTimes[0]).toBeGreaterThanOrEqual(20); }); + it("aborts while waiting for the configured Exa request delay", async () => { + resetSettingsForTest(); + resetExaSearchThrottleForTest(); + await Settings.init({ inMemory: true, overrides: { "exa.searchDelayMs": 1_000 } }); + let fetchCount = 0; + const fetchMock: FetchImpl = () => { + fetchCount += 1; + return Promise.resolve( + new Response(JSON.stringify(makeMockExaResponse()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }; + + await searchExa({ query: "first request", fetch: fetchMock }); + const controller = new AbortController(); + const startedAt = Date.now(); + const pending = searchExa({ query: "cancelled request", fetch: fetchMock, signal: controller.signal }); + await Bun.sleep(0); + controller.abort(new Error("cancelled Exa throttle wait")); + + await expect(pending).rejects.toThrow("cancelled Exa throttle wait"); + expect(Date.now() - startedAt).toBeLessThan(250); + expect(fetchCount).toBe(1); + }); + it("prefers summary over text for snippet field", async () => { const result = await searchExa({ query: "snippet test",