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:
abhay-codes07 2026-07-13 15:43:06 +05:30
parent 2cebe81512
commit 82ef13e720
No known key found for this signature in database
2 changed files with 143 additions and 3 deletions

View 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()
})
})

View file

@ -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,
),
)
}