mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-21 05:18:00 +00:00
fix(tools): route Responses API memory saves through /v4/conversations
When a customId is configured, the chat-completions path hands addMemoryTool the messages plus API credentials, so the save routes through /v4/conversations with the internal "conversation:" prefix stripped back off. The Responses path built the same prefixed memoryCustomId but passed neither messages nor credentials, so the conversation branch never ran and the save fell through to client.add() with "conversation:<id>" as the stored customId — a value customId validation rejects (alphanumeric/hyphen/underscore only). The error is swallowed by design (memory saves must not break the user's API call), which means every Responses API call with a customId silently saved nothing. Hand the input over as a single user message together with the credentials, mirroring the chat path, so conversation grouping works for the Responses API too. Also strip the internal prefix in the client.add fallback so the routing marker can never leak into a stored customId on any path (previously the chat path had the same leak whenever SUPERMEMORY_API_KEY was unset). Covered with mocked-SDK middleware tests: a customId save routes through addConversation with the un-prefixed conversation id and never touches client.add; the fallback stores the user's configured id, not the prefixed marker; and the no-customId path is unchanged. Two of the three tests fail against the previous implementation.
This commit is contained in:
parent
2cebe81512
commit
82ef13e720
2 changed files with 143 additions and 3 deletions
121
packages/tools/src/openai/middleware.test.ts
Normal file
121
packages/tools/src/openai/middleware.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
// Mock the Supermemory SDK and the conversations client so the middleware's
|
||||
// memory-save routing can be asserted without network access.
|
||||
const { clientAddMock, addConversationMock } = vi.hoisted(() => ({
|
||||
clientAddMock: vi.fn(),
|
||||
addConversationMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("supermemory", () => ({
|
||||
default: class MockSupermemory {
|
||||
add = clientAddMock
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../conversations-client", () => ({
|
||||
addConversation: addConversationMock,
|
||||
}))
|
||||
|
||||
import { createOpenAIMiddleware } from "./middleware"
|
||||
|
||||
const EMPTY_PROFILE = {
|
||||
profile: { static: [], dynamic: [] },
|
||||
searchResults: { results: [] },
|
||||
}
|
||||
|
||||
function makeFakeOpenAI() {
|
||||
return {
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn().mockResolvedValue({ id: "chatcmpl-1" }),
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
create: vi.fn().mockResolvedValue({ id: "resp-1" }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("createOpenAIMiddleware Responses API memory save", () => {
|
||||
beforeEach(() => {
|
||||
clientAddMock.mockReset().mockResolvedValue({ id: "mem-1" })
|
||||
addConversationMock.mockReset().mockResolvedValue({ id: "conv-doc-1" })
|
||||
process.env.SUPERMEMORY_API_KEY = "sm_test_key"
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => EMPTY_PROFILE,
|
||||
})),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it("routes a customId save through /v4/conversations like the chat path", async () => {
|
||||
const openai = makeFakeOpenAI()
|
||||
// biome-ignore lint/suspicious/noExplicitAny: minimal OpenAI stub for the wrapped surface
|
||||
createOpenAIMiddleware(openai as any, "user-1", {
|
||||
containerTag: "user-1",
|
||||
customId: "conv-1",
|
||||
addMemory: "always",
|
||||
mode: "query",
|
||||
})
|
||||
|
||||
await openai.responses.create({ input: "I love hiking", instructions: "" })
|
||||
|
||||
expect(addConversationMock).toHaveBeenCalledTimes(1)
|
||||
const payload = addConversationMock.mock.calls[0]?.[0]
|
||||
expect(payload.conversationId).toBe("conv-1")
|
||||
expect(payload.messages).toEqual([
|
||||
{ role: "user", content: "I love hiking" },
|
||||
])
|
||||
// The raw client.add fallback (which would have stored the internal
|
||||
// "conversation:" prefix as the customId) must not fire.
|
||||
expect(clientAddMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("never stores the internal conversation: prefix in the add fallback", async () => {
|
||||
const openai = makeFakeOpenAI()
|
||||
// biome-ignore lint/suspicious/noExplicitAny: minimal OpenAI stub for the wrapped surface
|
||||
createOpenAIMiddleware(openai as any, "user-1", {
|
||||
containerTag: "user-1",
|
||||
customId: "conv-1",
|
||||
addMemory: "always",
|
||||
mode: "query",
|
||||
})
|
||||
|
||||
// Without an API key the conversation branch cannot run, so the save
|
||||
// falls through to client.add — the stored customId must be the
|
||||
// user's configured id, not the prefixed routing marker.
|
||||
delete process.env.SUPERMEMORY_API_KEY
|
||||
|
||||
await openai.responses.create({ input: "I love hiking", instructions: "" })
|
||||
|
||||
expect(addConversationMock).not.toHaveBeenCalled()
|
||||
expect(clientAddMock).toHaveBeenCalledTimes(1)
|
||||
expect(clientAddMock.mock.calls[0]?.[0]?.customId).toBe("conv-1")
|
||||
})
|
||||
|
||||
it("saves without a customId via plain client.add", async () => {
|
||||
const openai = makeFakeOpenAI()
|
||||
// biome-ignore lint/suspicious/noExplicitAny: minimal OpenAI stub for the wrapped surface
|
||||
createOpenAIMiddleware(openai as any, "user-1", {
|
||||
containerTag: "user-1",
|
||||
addMemory: "always",
|
||||
mode: "query",
|
||||
// biome-ignore lint/suspicious/noExplicitAny: exercising the optional-customId path
|
||||
} as any)
|
||||
|
||||
await openai.responses.create({ input: "I love hiking", instructions: "" })
|
||||
|
||||
expect(addConversationMock).not.toHaveBeenCalled()
|
||||
expect(clientAddMock).toHaveBeenCalledTimes(1)
|
||||
const arg = clientAddMock.mock.calls[0]?.[0]
|
||||
expect(arg.content).toBe("I love hiking")
|
||||
expect(arg.customId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -367,11 +367,13 @@ const addMemoryTool = async (
|
|||
return
|
||||
}
|
||||
|
||||
// Fallback to old behavior for non-conversation memories
|
||||
// Fallback to old behavior for non-conversation memories. The
|
||||
// "conversation:" prefix is an internal routing marker — never let
|
||||
// it reach the stored customId (colons fail customId validation).
|
||||
const response = await client.add({
|
||||
content,
|
||||
containerTags: [containerTag],
|
||||
customId,
|
||||
customId: customId?.replace(/^conversation:/, ""),
|
||||
})
|
||||
|
||||
logger.info("Memory saved successfully", {
|
||||
|
|
@ -548,9 +550,26 @@ export function createOpenAIMiddleware(
|
|||
if (addMemory === "always" && input?.trim()) {
|
||||
const content = customId ? `Input: ${input}` : input
|
||||
const memoryCustomId = customId ? `conversation:${customId}` : undefined
|
||||
// Mirror the chat path: hand the input over as messages together
|
||||
// with the credentials so a configured customId routes through
|
||||
// /v4/conversations. Without these, addMemoryTool fell through to
|
||||
// client.add() with the internal "conversation:" prefix baked
|
||||
// into the stored customId — which the API rejects, so Responses
|
||||
// API memory saves silently failed whenever customId was set.
|
||||
const inputAsMessages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] =
|
||||
[{ role: "user", content: input }]
|
||||
|
||||
operations.push(
|
||||
addMemoryTool(client, containerTag, content, memoryCustomId, logger),
|
||||
addMemoryTool(
|
||||
client,
|
||||
containerTag,
|
||||
content,
|
||||
memoryCustomId,
|
||||
logger,
|
||||
customId ? inputAsMessages : undefined,
|
||||
process.env.SUPERMEMORY_API_KEY,
|
||||
baseUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue