From 9381369eee333462b17f3f54c48f339a3049e19a Mon Sep 17 00:00:00 2001 From: Agnik47 <140933190+Agnik47@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:17:05 +0530 Subject: [PATCH 1/2] fix(tools): keep the forget-memory timeout when a caller passes a signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `forgetMemoryRequest` combined the caller's signal and the 30s abort with `??`, making them mutually exclusive. Passing a cancellation signal removed the timeout, so a hung `DELETE /v4/memories` could wedge the tool call again — the exact condition #1451 set out to remove. There was also no way for a caller to ask for both cancellation and a timeout. Compose the two with `AbortSignal.any` instead of choosing between them. `AbortSignal.any` is available in Node 20.3+, Bun and workerd. No production call site passes `options` today (`ai-sdk.ts` and `openai/tools.ts` both omit it), so this was latent rather than live. The existing test asserted the buggy behaviour (`init.signal` being the caller's own signal), so it is replaced by two tests that pin the composed semantics: aborting the caller aborts the request, and the timeout leg still aborts the request on its own. Both fail against the previous implementation. Fixes #1549 --- packages/tools/src/shared/forget-memory.ts | 6 +++- packages/tools/src/tool-operations.test.ts | 36 ++++++++++++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) 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 () => { From 014e1e924d1abeca473906abd8d7b19964519176 Mon Sep 17 00:00:00 2001 From: Agnik47 <140933190+Agnik47@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:46:52 +0530 Subject: [PATCH 2/2] fix(mcp): keep the getDocuments timeout when a caller passes a signal Same pattern as forgetMemoryRequest: `options?.signal ?? AbortSignal.timeout(...)` dropped the 30s bound whenever a caller supplied its own signal. Compose the two with AbortSignal.any so the caller signal adds cancellation instead of replacing the timeout. --- apps/mcp/src/server/client/index.test.ts | 66 ++++++++++++++++++++++++ apps/mcp/src/server/client/index.ts | 9 +++- 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 apps/mcp/src/server/client/index.test.ts diff --git a/apps/mcp/src/server/client/index.test.ts b/apps/mcp/src/server/client/index.test.ts new file mode 100644 index 00000000..68ea5703 --- /dev/null +++ b/apps/mcp/src/server/client/index.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { SupermemoryClient } from "./index" + +const API_URL = "https://api.example.com" + +describe("SupermemoryClient.getDocuments", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + function stubFetch() { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + documents: [], + pagination: { + currentPage: 1, + limit: 200, + totalItems: 0, + totalPages: 0, + }, + }), + ) + vi.stubGlobal("fetch", fetchMock) + return fetchMock + } + + it("cancels through a caller-provided signal", async () => { + const fetchMock = stubFetch() + const controller = new AbortController() + + await new SupermemoryClient("sm_test_key", "user_1", API_URL).getDocuments( + ["user_1"], + 1, + 200, + { signal: controller.signal }, + ) + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit] + 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() + + await new SupermemoryClient("sm_test_key", "user_1", API_URL).getDocuments( + ["user_1"], + 1, + 200, + { signal: new AbortController().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) + }) +}) diff --git a/apps/mcp/src/server/client/index.ts b/apps/mcp/src/server/client/index.ts index a2ccc8e7..f25f014a 100644 --- a/apps/mcp/src/server/client/index.ts +++ b/apps/mcp/src/server/client/index.ts @@ -336,7 +336,14 @@ export class SupermemoryClient { options?: { signal?: AbortSignal }, ): Promise { try { - const 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. + const signal = options?.signal + ? AbortSignal.any([ + options.signal, + AbortSignal.timeout(FETCH_TIMEOUT_MS), + ]) + : AbortSignal.timeout(FETCH_TIMEOUT_MS) const response = await fetch(`${this.apiUrl}/v3/documents/documents`, { method: "POST", headers: {