diff --git a/packages/tools/src/shared/forget-memory.ts b/packages/tools/src/shared/forget-memory.ts index 8691c92a..97b7e510 100644 --- a/packages/tools/src/shared/forget-memory.ts +++ b/packages/tools/src/shared/forget-memory.ts @@ -33,7 +33,11 @@ export async function forgetMemoryRequest( Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(params), - signal: options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS), + // Compose rather than choose: a caller-supplied signal must add cancellation + // on top of the timeout, not replace it, or the request becomes unbounded. + signal: options?.signal + ? AbortSignal.any([options.signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]) + : AbortSignal.timeout(FETCH_TIMEOUT_MS), }) if (!response.ok) { diff --git a/packages/tools/src/tool-operations.test.ts b/packages/tools/src/tool-operations.test.ts index 136a19be..028124d6 100644 --- a/packages/tools/src/tool-operations.test.ts +++ b/packages/tools/src/tool-operations.test.ts @@ -112,7 +112,7 @@ describe("memoryForget", () => { expect(init.signal).toBeInstanceOf(AbortSignal) }) - it("uses a caller-provided signal instead of creating a timeout", async () => { + it("cancels through a caller-provided signal", async () => { const fetchMock = stubFetch() const controller = new AbortController() @@ -124,7 +124,39 @@ describe("memoryForget", () => { ) const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] - expect(init.signal).toBe(controller.signal) + // The request signal is a composite, not the caller's own, but aborting + // the caller still aborts the request. + expect(init.signal).not.toBe(controller.signal) + controller.abort() + expect(init.signal?.aborted).toBe(true) + }) + + it("keeps the timeout when a caller-provided signal is present", async () => { + const timeoutController = new AbortController() + const timeoutSpy = vi + .spyOn(AbortSignal, "timeout") + .mockReturnValue(timeoutController.signal) + const fetchMock = stubFetch() + const controller = new AbortController() + + try { + await forgetMemoryRequest( + API_KEY, + { containerTag: "user_1", id: "mem_1" }, + undefined, + { signal: controller.signal }, + ) + + expect(timeoutSpy).toHaveBeenCalledWith(30_000) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + // Firing only the timeout leg aborts the request: a caller signal adds + // cancellation, it does not remove the 30s bound. + timeoutController.abort() + expect(init.signal?.aborted).toBe(true) + } finally { + timeoutSpy.mockRestore() + } }) it("throws a descriptive error on non-2xx responses", async () => {