fix(tools): repair SDK call mismatches that broke five tools (#1186)

This commit is contained in:
Abhay Singh 2026-07-11 07:00:41 +05:30 committed by GitHub
parent 67b6585346
commit 501613b6bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 349 additions and 50 deletions

View file

@ -7,6 +7,7 @@ import {
TOOL_DESCRIPTIONS,
getContainerTags,
} from "./tools-shared"
import { forgetMemoryRequest } from "./shared/forget-memory"
import type { SupermemoryToolsConfig } from "./types"
// Export individual tool creators
@ -191,23 +192,21 @@ export const documentListTool = (
.optional()
.default(DEFAULT_VALUES.limit)
.describe(PARAMETER_DESCRIPTIONS.limit),
offset: z.number().optional().describe(PARAMETER_DESCRIPTIONS.offset),
status: z.string().optional().describe(PARAMETER_DESCRIPTIONS.status),
page: z.number().optional().describe(PARAMETER_DESCRIPTIONS.page),
}),
execute: async ({ containerTag, limit, offset, status }) => {
execute: async ({ containerTag, limit, page }) => {
try {
const tag = containerTag || containerTags[0]
const response = await client.documents.list({
containerTags: [tag],
limit: limit || DEFAULT_VALUES.limit,
...(offset !== undefined && { offset }),
...(status && { status }),
...(page !== undefined && { page }),
})
return {
success: true,
documents: response.documents,
documents: response.memories,
pagination: response.pagination,
}
} catch (error) {
@ -236,7 +235,7 @@ export const documentDeleteTool = (
}),
execute: async ({ documentId }) => {
try {
await client.documents.delete({ docId: documentId })
await client.documents.delete(documentId)
return {
success: true,
@ -303,11 +302,6 @@ export const memoryForgetTool = (
apiKey: string,
config?: SupermemoryToolsConfig,
) => {
const client = new Supermemory({
apiKey,
...(config?.baseUrl ? { baseURL: config.baseUrl } : {}),
})
const containerTags = getContainerTags(config)
return tool({
@ -335,12 +329,16 @@ export const memoryForgetTool = (
const tag = containerTag || containerTags[0]
await client.memories.forget({
containerTag: tag,
...(memoryId && { id: memoryId }),
...(memoryContent && { content: memoryContent }),
...(reason && { reason }),
})
await forgetMemoryRequest(
apiKey,
{
containerTag: tag as string,
...(memoryId && { id: memoryId }),
...(memoryContent && { content: memoryContent }),
...(reason && { reason }),
},
config?.baseUrl,
)
return {
success: true,

View file

@ -96,7 +96,10 @@ export class ClaudeMemoryTool {
}
return await this.create(command.path, command.file_text)
case "str_replace":
if (!command.old_str || !command.new_str) {
// new_str may legitimately be "" (deleting text), so only reject
// when it is missing entirely. old_str must be non-empty — replacing
// the empty string would prepend instead of replacing.
if (!command.old_str || command.new_str === undefined) {
return {
success: false,
error: "old_str and new_str are required for str_replace command",
@ -108,7 +111,11 @@ export class ClaudeMemoryTool {
command.new_str,
)
case "insert":
if (command.insert_line === undefined || !command.insert_text) {
// insert_text may be "" (inserting a blank line).
if (
command.insert_line === undefined ||
command.insert_text === undefined
) {
return {
success: false,
error:
@ -487,9 +494,9 @@ export class ClaudeMemoryTool {
}
}
// Delete using the document ID
// Note: We'll need to implement this based on supermemory's delete API
// For now, we'll return a success message
const documentId =
readResult.document.documentId ?? this.normalizePathToCustomId(filePath)
await this.client.documents.delete(documentId)
return {
success: true,
@ -544,7 +551,14 @@ export class ClaudeMemoryTool {
},
})
// Delete the old document (would need proper delete API)
// Remove the old document so the previous path stops showing up in
// listings and search. Skip when both paths normalize to the same
// customId — the add above already replaced the content.
const oldNormalizedId = this.normalizePathToCustomId(oldPath)
if (oldNormalizedId !== newNormalizedId) {
const oldDocumentId = readResult.document.documentId ?? oldNormalizedId
await this.client.documents.delete(oldDocumentId)
}
return {
success: true,

View file

@ -6,6 +6,7 @@ import {
TOOL_DESCRIPTIONS,
getContainerTags,
} from "../tools-shared"
import { forgetMemoryRequest } from "../shared/forget-memory"
import type { SupermemoryToolsConfig } from "../types"
/**
@ -36,7 +37,7 @@ export interface ProfileResult {
export interface DocumentListResult {
success: boolean
documents?: Awaited<ReturnType<Supermemory["documents"]["list"]>>["documents"]
documents?: Awaited<ReturnType<Supermemory["documents"]["list"]>>["memories"]
pagination?: Awaited<
ReturnType<Supermemory["documents"]["list"]>
>["pagination"]
@ -139,13 +140,9 @@ export const memoryToolSchemas = {
description: PARAMETER_DESCRIPTIONS.limit,
default: DEFAULT_VALUES.limit,
},
offset: {
page: {
type: "number",
description: PARAMETER_DESCRIPTIONS.offset,
},
status: {
type: "string",
description: PARAMETER_DESCRIPTIONS.status,
description: PARAMETER_DESCRIPTIONS.page,
},
},
required: [],
@ -359,13 +356,11 @@ export function createDocumentListFunction(
return async function documentList({
containerTag,
limit,
offset,
status,
page,
}: {
containerTag?: string
limit?: number
offset?: number
status?: string
page?: number
}): Promise<DocumentListResult> {
try {
const tag = containerTag || containerTags[0]
@ -373,13 +368,12 @@ export function createDocumentListFunction(
const response = await client.documents.list({
containerTags: [tag],
limit: limit || DEFAULT_VALUES.limit,
...(offset !== undefined && { offset }),
...(status && { status }),
...(page !== undefined && { page }),
})
return {
success: true,
documents: response.documents,
documents: response.memories,
pagination: response.pagination,
}
} catch (error) {
@ -470,7 +464,7 @@ export function createMemoryForgetFunction(
apiKey: string,
config?: SupermemoryToolsConfig,
) {
const { client, containerTags } = createClient(apiKey, config)
const containerTags = getContainerTags(config)
return async function memoryForget({
containerTag,
@ -493,12 +487,16 @@ export function createMemoryForgetFunction(
const tag = containerTag || containerTags[0]
await client.memories.forget({
containerTag: tag,
...(memoryId && { id: memoryId }),
...(memoryContent && { content: memoryContent }),
...(reason && { reason }),
})
await forgetMemoryRequest(
apiKey,
{
containerTag: tag as string,
...(memoryId && { id: memoryId }),
...(memoryContent && { content: memoryContent }),
...(reason && { reason }),
},
config?.baseUrl,
)
return {
success: true,

View file

@ -0,0 +1,38 @@
const DEFAULT_BASE_URL = "https://api.supermemory.ai"
export interface ForgetMemoryParams {
containerTag: string
id?: string
content?: string
reason?: string
}
/**
* Marks a memory as forgotten via `DELETE /v4/memories`.
*
* The supermemory SDK version this package depends on (v3) has no
* `memories.forget` method, so the endpoint is called directly the same
* pattern the middleware already uses for `/v4/profile` and
* `/v4/conversations`.
*/
export async function forgetMemoryRequest(
apiKey: string,
params: ForgetMemoryParams,
baseUrl: string = DEFAULT_BASE_URL,
): Promise<void> {
const response = await fetch(`${baseUrl}/v4/memories`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(params),
})
if (!response.ok) {
const errorText = await response.text().catch(() => "Unknown error")
throw new Error(
`Supermemory forget memory failed: ${response.status} ${response.statusText}. ${errorText}`,
)
}
}

View file

@ -0,0 +1,253 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
// Mock the Supermemory SDK (same pattern as claude-memory.test.ts) so tool
// executions can be verified deterministically without network access.
const documentsDelete = vi.fn()
const documentsList = vi.fn()
const searchExecute = vi.fn()
const clientAdd = vi.fn()
vi.mock("supermemory", () => {
return {
default: class MockSupermemory {
search = { execute: searchExecute }
add = clientAdd
documents = {
delete: documentsDelete,
list: documentsList,
add: vi.fn(),
}
},
}
})
import * as aiSdk from "./ai-sdk"
import { ClaudeMemoryTool } from "./claude-memory"
import { forgetMemoryRequest } from "./shared/forget-memory"
import * as openAi from "./openai/tools"
const API_KEY = "sm_test_key"
type ToolWithExecute = { execute: (args: Record<string, unknown>) => unknown }
function executeTool(tool: unknown, args: Record<string, unknown>) {
return (tool as ToolWithExecute).execute(args)
}
beforeEach(() => {
documentsDelete.mockReset().mockResolvedValue(undefined)
documentsList.mockReset().mockResolvedValue({
memories: [{ id: "doc_1", title: "Doc one" }],
pagination: { currentPage: 1, totalItems: 1, totalPages: 1 },
})
searchExecute.mockReset()
clientAdd.mockReset().mockResolvedValue({ id: "doc_new" })
vi.unstubAllGlobals()
})
describe("documentDelete", () => {
it("ai-sdk variant passes the document id string to the SDK", async () => {
const tool = aiSdk.documentDeleteTool(API_KEY)
const result = (await executeTool(tool, { documentId: "doc_123" })) as {
success: boolean
}
expect(result.success).toBe(true)
expect(documentsDelete).toHaveBeenCalledWith("doc_123")
})
})
describe("documentList", () => {
it("ai-sdk variant returns the SDK's memories array as documents", async () => {
const tool = aiSdk.documentListTool(API_KEY)
const result = (await executeTool(tool, {})) as {
success: boolean
documents?: Array<{ id: string }>
}
expect(result.success).toBe(true)
expect(result.documents).toEqual([{ id: "doc_1", title: "Doc one" }])
})
it("openai variant forwards page-based pagination to the SDK", async () => {
const documentList = openAi.createDocumentListFunction(API_KEY)
const result = await documentList({ limit: 5, page: 3 })
expect(result.success).toBe(true)
expect(result.documents).toEqual([{ id: "doc_1", title: "Doc one" }])
expect(documentsList).toHaveBeenCalledWith(
expect.objectContaining({ limit: 5, page: 3 }),
)
})
})
describe("memoryForget", () => {
function stubFetch(response = new Response(null, { status: 200 })) {
const fetchMock = vi.fn().mockResolvedValue(response)
vi.stubGlobal("fetch", fetchMock)
return fetchMock
}
it("issues DELETE /v4/memories with the forget payload", async () => {
const fetchMock = stubFetch()
await forgetMemoryRequest(API_KEY, {
containerTag: "user_1",
id: "mem_1",
reason: "outdated",
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toBe("https://api.supermemory.ai/v4/memories")
expect(init.method).toBe("DELETE")
expect(init.headers).toMatchObject({
Authorization: `Bearer ${API_KEY}`,
})
expect(JSON.parse(init.body as string)).toEqual({
containerTag: "user_1",
id: "mem_1",
reason: "outdated",
})
})
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/)
})
it("ai-sdk tool forgets by content through the endpoint", async () => {
const fetchMock = stubFetch()
const tool = aiSdk.memoryForgetTool(API_KEY, {
containerTags: ["user_2"],
})
const result = (await executeTool(tool, {
memoryContent: "stale fact",
})) as { success: boolean }
expect(result.success).toBe(true)
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(JSON.parse(init.body as string)).toEqual({
containerTag: "user_2",
content: "stale fact",
})
})
it("openai tool surfaces endpoint failures as tool errors", async () => {
stubFetch(new Response("boom", { status: 500, statusText: "Server Error" }))
const memoryForget = openAi.createMemoryForgetFunction(API_KEY)
const result = await memoryForget({ memoryId: "mem_9" })
expect(result.success).toBe(false)
expect(result.error).toMatch(/500/)
})
it("still requires an id or content", async () => {
const fetchMock = stubFetch()
const memoryForget = openAi.createMemoryForgetFunction(API_KEY)
const result = await memoryForget({})
expect(result.success).toBe(false)
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe("ClaudeMemoryTool", () => {
const FILE_PATH = "/memories/prefs.txt"
const CUSTOM_ID = "memories_prefs_txt"
function mockFileDocument(content: string) {
searchExecute.mockResolvedValue({
results: [
{
documentId: CUSTOM_ID,
content,
metadata: { file_path: FILE_PATH },
},
],
})
}
it("str_replace accepts an empty new_str to delete text", async () => {
mockFileDocument("keep this\nremove this\n")
const tool = new ClaudeMemoryTool(API_KEY)
const result = await tool.handleCommand({
command: "str_replace",
path: FILE_PATH,
old_str: "remove this\n",
new_str: "",
})
expect(result.success).toBe(true)
expect(clientAdd).toHaveBeenCalledWith(
expect.objectContaining({ content: "keep this\n" }),
)
})
it("str_replace still rejects a missing new_str", async () => {
const tool = new ClaudeMemoryTool(API_KEY)
const result = await tool.handleCommand({
command: "str_replace",
path: FILE_PATH,
old_str: "something",
})
expect(result.success).toBe(false)
expect(result.error).toContain("new_str")
})
it("insert accepts an empty insert_text for blank lines", async () => {
mockFileDocument("line1\nline2")
const tool = new ClaudeMemoryTool(API_KEY)
const result = await tool.handleCommand({
command: "insert",
path: FILE_PATH,
insert_line: 2,
insert_text: "",
})
expect(result.success).toBe(true)
expect(clientAdd).toHaveBeenCalledWith(
expect.objectContaining({ content: "line1\n\nline2" }),
)
})
it("delete actually deletes the backing document", async () => {
mockFileDocument("contents")
const tool = new ClaudeMemoryTool(API_KEY)
const result = await tool.handleCommand({
command: "delete",
path: FILE_PATH,
})
expect(result.success).toBe(true)
expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID)
})
it("rename removes the old document after creating the new one", async () => {
mockFileDocument("contents")
const tool = new ClaudeMemoryTool(API_KEY)
const result = await tool.handleCommand({
command: "rename",
path: FILE_PATH,
new_path: "/memories/renamed.txt",
})
expect(result.success).toBe(true)
expect(clientAdd).toHaveBeenCalledWith(
expect.objectContaining({ customId: "memories_renamed_txt" }),
)
expect(documentsDelete).toHaveBeenCalledWith(CUSTOM_ID)
})
})

View file

@ -11,7 +11,7 @@ export const TOOL_DESCRIPTIONS = {
getProfile:
"Get user profile containing static memories (permanent facts) and dynamic memories (recent context). Optionally include search results by providing a query.",
documentList:
"List stored documents with optional filtering by container tag, status, and pagination. Useful for browsing or managing saved content.",
"List stored documents with optional filtering by container tag and page-based pagination. Useful for browsing or managing saved content.",
documentDelete:
"Delete a document and its associated memories by document ID or customId. Deletes are permanent. Use when user wants to remove saved content.",
documentAdd:
@ -30,9 +30,7 @@ export const PARAMETER_DESCRIPTIONS = {
"The text content of the memory to add. This should be a single sentence or a short paragraph.",
containerTag: "Tag to filter/scope the operation (e.g., user ID, project ID)",
query: "Optional search query to include relevant search results",
offset: "Number of items to skip for pagination (default: 0)",
status:
"Filter documents by processing status (e.g., 'completed', 'processing', 'failed')",
page: "Page number to fetch, 1-based (default: 1)",
documentId: "The unique identifier of the document to operate on",
content: "The content to add - can be text, URL, or other supported formats",
title: "Optional title for the document",

View file

@ -310,7 +310,7 @@ describe("@supermemory/tools", () => {
const definitions = openAi.getToolDefinitions()
expect(definitions).toBeDefined()
expect(definitions.length).toBe(2)
expect(definitions.length).toBe(7)
// Check searchMemories
const searchTool = definitions.find(