diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index 2c58df630..a26143d67 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -66,12 +66,23 @@ function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + signal.throwIfAborted(); + const { promise: aborted, reject } = Promise.withResolvers(); + const onAbort = (): void => rejectWithAbortReason(reject, signal); + signal.addEventListener("abort", onAbort, { once: true }); + return Promise.race([promise, aborted]).finally(() => { + signal.removeEventListener("abort", onAbort); + }); +} + async function waitForExaSearchSlot(signal: AbortSignal | undefined): Promise { const delayMs = configuredExaSearchDelayMs(); if (delayMs <= 0) return; const prior = exaSearchThrottle.catch(() => {}); - const current = prior.then(async () => { + const queued = prior.then(async () => { signal?.throwIfAborted(); const waitMs = Math.max(0, nextExaSearchRequestAt - Date.now()); if (waitMs > 0) { @@ -80,8 +91,8 @@ async function waitForExaSearchSlot(signal: AbortSignal | undefined): Promise {}); - await current; + exaSearchThrottle = queued.catch(() => {}); + await waitUntilDoneOrAborted(queued, signal); } /** Reset Exa request pacing state for isolated provider tests. */ 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 6df9fba7f..34065411c 100644 --- a/packages/coding-agent/test/tools/web-search-exa.test.ts +++ b/packages/coding-agent/test/tools/web-search-exa.test.ts @@ -349,6 +349,38 @@ describe("searchExa", () => { expect(fetchCount).toBe(1); }); + it("aborts while queued behind another Exa throttle wait", 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 secondController = new AbortController(); + const thirdController = new AbortController(); + const second = searchExa({ query: "second request", fetch: fetchMock, signal: secondController.signal }); + const startedAt = Date.now(); + const third = searchExa({ query: "third request", fetch: fetchMock, signal: thirdController.signal }); + await Bun.sleep(0); + thirdController.abort(new Error("cancelled queued Exa throttle wait")); + + await expect(third).rejects.toThrow("cancelled queued Exa throttle wait"); + expect(Date.now() - startedAt).toBeLessThan(250); + expect(fetchCount).toBe(1); + + secondController.abort(new Error("cleanup second Exa throttle wait")); + await expect(second).rejects.toThrow("cleanup second Exa throttle wait"); + }); + it("prefers summary over text for snippet field", async () => { const result = await searchExa({ query: "snippet test",