Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/tools/src/shared/forget-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,17 @@ export async function forgetMemoryRequest(
baseUrl: string = DEFAULT_BASE_URL,
options?: ForgetMemoryRequestOptions,
): Promise<void> {
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS)
const response = await fetch(`${baseUrl}/v4/memories`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(params),
signal: options?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS),
signal: options?.signal
? AbortSignal.any([options.signal, timeoutSignal])
: timeoutSignal,
})

if (!response.ok) {
Expand Down
89 changes: 77 additions & 12 deletions packages/tools/src/tool-operations.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { beforeEach, describe, expect, it, onTestFinished, vi } from "vitest"

// Mock the Supermemory SDK (same pattern as claude-memory.test.ts) so tool
// executions can be verified deterministically without network access.
Expand Down Expand Up @@ -88,6 +88,20 @@ describe("memoryForget", () => {
return fetchMock
}

function forget(options?: { signal?: AbortSignal }) {
return forgetMemoryRequest(
API_KEY,
{ containerTag: "user_1", id: "mem_1" },
undefined,
options,
)
}

function signalOf(fetchMock: ReturnType<typeof stubFetch>) {
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
return init.signal as AbortSignal
}

it("issues DELETE /v4/memories with the forget payload", async () => {
const fetchMock = stubFetch()

Expand All @@ -112,27 +126,78 @@ describe("memoryForget", () => {
expect(init.signal).toBeInstanceOf(AbortSignal)
})

it("uses a caller-provided signal instead of creating a timeout", async () => {
it("composes a caller-provided signal with the timeout", async () => {
const fetchMock = stubFetch()
const controller = new AbortController()

await forgetMemoryRequest(
API_KEY,
{ containerTag: "user_1", id: "mem_1" },
undefined,
{ signal: controller.signal },
await forget({ signal: controller.signal })

const signal = signalOf(fetchMock)
expect(signal).not.toBe(controller.signal)
expect(signal.aborted).toBe(false)

const reason = new Error("caller cancelled")
controller.abort(reason)
expect(signal.aborted).toBe(true)
expect(signal.reason).toBe(reason)
})

it("still times out while a caller-provided signal stays open", async () => {
const realTimeout = AbortSignal.timeout.bind(AbortSignal)
const timeout = vi
.spyOn(AbortSignal, "timeout")
.mockImplementation(() => realTimeout(5))
onTestFinished(() => timeout.mockRestore())
const fetchMock = stubFetch()
const controller = new AbortController()

await forget({ signal: controller.signal })

expect(timeout).toHaveBeenCalledWith(30_000)
const signal = signalOf(fetchMock)
await vi.waitFor(() => expect(signal.aborted).toBe(true))
expect((signal.reason as Error).name).toBe("TimeoutError")
expect(controller.signal.aborted).toBe(false)
})

it("forwards an already-aborted caller signal", async () => {
const fetchMock = stubFetch()
const reason = new Error("cancelled before dispatch")

await forget({ signal: AbortSignal.abort(reason) })

expect(signalOf(fetchMock).aborted).toBe(true)
expect(signalOf(fetchMock).reason).toBe(reason)
})

it("uses the bare timeout when options carry no signal", async () => {
const timeout = vi.spyOn(AbortSignal, "timeout")
onTestFinished(() => timeout.mockRestore())
const fetchMock = stubFetch()

await forget({})

expect(timeout).toHaveBeenCalledWith(30_000)
expect(signalOf(fetchMock)).toBe(timeout.mock.results[0]?.value)
})

it("surfaces an aborted request to the caller", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockRejectedValue(
new DOMException("The operation timed out", "TimeoutError"),
),
)

const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(init.signal).toBe(controller.signal)
await expect(forget()).rejects.toThrow("The operation timed out")
})

it("throws a descriptive error on non-2xx responses", async () => {
stubFetch(new Response("nope", { status: 401, statusText: "Unauthorized" }))

await expect(
forgetMemoryRequest(API_KEY, { containerTag: "user_1", id: "mem_1" }),
).rejects.toThrow(/401/)
await expect(forget()).rejects.toThrow(/401/)
})

it("ai-sdk tool forgets by content through the endpoint", async () => {
Expand Down
Loading