From 82ef13e720dd78f9b16ff0dad612477f3bc430a4 Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Mon, 13 Jul 2026 15:43:06 +0530 Subject: [PATCH 1/3] fix(tools): route Responses API memory saves through /v4/conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:" 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. --- packages/tools/src/openai/middleware.test.ts | 121 +++++++++++++++++++ packages/tools/src/openai/middleware.ts | 25 +++- 2 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 packages/tools/src/openai/middleware.test.ts diff --git a/packages/tools/src/openai/middleware.test.ts b/packages/tools/src/openai/middleware.test.ts new file mode 100644 index 00000000..6efbd852 --- /dev/null +++ b/packages/tools/src/openai/middleware.test.ts @@ -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() + }) +}) diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index 7fc4267e..78df52da 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -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, + ), ) } From a16272b35288654f6a15c2ac3c4b788e47e5b9a9 Mon Sep 17 00:00:00 2001 From: Vedant Mahajan Date: Thu, 13 Aug 2026 16:22:49 +0530 Subject: [PATCH 2/3] remove test file --- packages/tools/src/openai/middleware.test.ts | 121 ------------------- 1 file changed, 121 deletions(-) delete mode 100644 packages/tools/src/openai/middleware.test.ts diff --git a/packages/tools/src/openai/middleware.test.ts b/packages/tools/src/openai/middleware.test.ts deleted file mode 100644 index 6efbd852..00000000 --- a/packages/tools/src/openai/middleware.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -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() - }) -}) From bcfd64c73803a896b2636ae4305edb02aed882e0 Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Fri, 14 Aug 2026 22:58:55 +0530 Subject: [PATCH 3/3] fix(tools): handle structured Responses input and keep conversation ids stable Addresses review feedback on the Responses API memory routing: - The wrapper collapsed any non-string `input` to "" (`typeof input === "string" ? input : ""`), so structured or multi-modal Responses requests skipped both memory search and saving. Add extractResponsesInput to flatten string or structured array input into a text query plus role-tagged messages, and route the reconstructed messages through /v4/conversations. - Stop rewriting the stored customId in the client.add fallback. The backend already stores conversation memories under the "conversation:" id, so stripping the prefix would orphan a conversation's earlier turns. Unit tests cover string, empty, structured multi-part, multi-turn, role normalization, and text-less items. --- packages/tools/src/openai/middleware.ts | 108 +++++++++++++++--- .../with-supermemory/responses-input.test.ts | 90 +++++++++++++++ 2 files changed, 183 insertions(+), 15 deletions(-) create mode 100644 packages/tools/test/with-supermemory/responses-input.test.ts diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index 78df52da..36b1fe48 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -280,6 +280,85 @@ const getConversationContent = ( .join("\n\n") } +/** + * Flattens the Responses API `input` into a text query and chat-style messages. + * + * `input` may be a plain string or an array of structured items where each + * message carries multi-part content (`input_text`, `output_text`, images). + * The previous `typeof input === "string" ? input : ""` collapsed every + * structured or multi-modal request to an empty string, so those turns skipped + * both memory search and saving. Here we pull out the text for search and + * rebuild role-tagged messages so the save still routes through + * /v4/conversations. + * + * @param input - The Responses API `input` value (string or structured array) + * @returns The joined text query and the reconstructed messages + */ +export const extractResponsesInput = ( + input: unknown, +): { + text: string + messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] +} => { + if (typeof input === "string") { + return input.trim() + ? { text: input, messages: [{ role: "user", content: input }] } + : { text: "", messages: [] } + } + + if (!Array.isArray(input)) { + return { text: "", messages: [] } + } + + const textParts: string[] = [] + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [] + + const pushMessage = (role: string, text: string) => { + const normalizedRole = + role === "assistant" || role === "system" ? role : "user" + messages.push({ + role: normalizedRole, + content: text, + } as OpenAI.Chat.Completions.ChatCompletionMessageParam) + } + + const textFromContentPart = (part: unknown): string => { + if (typeof part === "string") return part + if (!part || typeof part !== "object") return "" + const record = part as Record + return typeof record.text === "string" ? record.text : "" + } + + for (const item of input) { + if (typeof item === "string") { + if (item.trim()) { + textParts.push(item) + pushMessage("user", item) + } + continue + } + if (!item || typeof item !== "object") continue + + const record = item as Record + const role = typeof record.role === "string" ? record.role : "user" + const content = record.content + + const text = + typeof content === "string" + ? content + : Array.isArray(content) + ? content.map(textFromContentPart).filter(Boolean).join("\n") + : "" + + if (text.trim()) { + textParts.push(text) + pushMessage(role, text) + } + } + + return { text: textParts.join("\n").trim(), messages } +} + /** * Adds a new memory to the SuperMemory system. * @@ -367,13 +446,14 @@ const addMemoryTool = async ( return } - // 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). + // Fallback for when we cannot reach /v4/conversations (no messages or + // API key). Keep the customId exactly as passed: the backend already + // stores conversation memories under the "conversation:" id, so + // rewriting it here would orphan a conversation's earlier turns. const response = await client.add({ content, containerTags: [containerTag], - customId: customId?.replace(/^conversation:/, ""), + customId, }) logger.info("Memory saved successfully", { @@ -532,7 +612,9 @@ export function createOpenAIMiddleware( ) } - const input = typeof params.input === "string" ? params.input : "" + const { text: input, messages: inputMessages } = extractResponsesInput( + params.input, + ) if (mode !== "profile" && !input) { logger.debug("No input found for Responses API, skipping memory search") @@ -550,15 +632,11 @@ 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 }] - + // Mirror the chat path: hand the reconstructed messages over with + // the credentials so a configured customId routes through + // /v4/conversations. Without messages, addMemoryTool falls back to + // client.add() with the "conversation:" customId; passing them lets + // structured and multi-turn Responses input be saved intact. operations.push( addMemoryTool( client, @@ -566,7 +644,7 @@ export function createOpenAIMiddleware( content, memoryCustomId, logger, - customId ? inputAsMessages : undefined, + customId ? inputMessages : undefined, process.env.SUPERMEMORY_API_KEY, baseUrl, ), diff --git a/packages/tools/test/with-supermemory/responses-input.test.ts b/packages/tools/test/with-supermemory/responses-input.test.ts new file mode 100644 index 00000000..84d95b66 --- /dev/null +++ b/packages/tools/test/with-supermemory/responses-input.test.ts @@ -0,0 +1,90 @@ +/** + * Unit tests for extractResponsesInput. + * + * The Responses API `input` can be a plain string or a structured array of + * items with multi-part content. The wrapper previously collapsed anything + * that was not a string to "", which silently skipped memory search and + * saving for structured or multi-modal requests. + */ + +import { describe, it, expect } from "vitest" +import { extractResponsesInput } from "../../src/openai/middleware" + +describe("extractResponsesInput", () => { + it("passes a plain string through as a single user message", () => { + const result = extractResponsesInput("Where do I live?") + + expect(result.text).toBe("Where do I live?") + expect(result.messages).toEqual([ + { role: "user", content: "Where do I live?" }, + ]) + }) + + it("returns nothing for an empty string", () => { + const result = extractResponsesInput(" ") + + expect(result.text).toBe("") + expect(result.messages).toEqual([]) + }) + + it("returns nothing for unsupported input types", () => { + expect(extractResponsesInput(undefined)).toEqual({ text: "", messages: [] }) + expect(extractResponsesInput(null)).toEqual({ text: "", messages: [] }) + expect(extractResponsesInput(42)).toEqual({ text: "", messages: [] }) + }) + + it("extracts text from structured multi-part content", () => { + const result = extractResponsesInput([ + { + role: "user", + content: [ + { type: "input_text", text: "Describe this" }, + { type: "input_image", image_url: "https://example.com/cat.png" }, + { type: "input_text", text: "in one word" }, + ], + }, + ]) + + expect(result.text).toBe("Describe this\nin one word") + expect(result.messages).toEqual([ + { role: "user", content: "Describe this\nin one word" }, + ]) + }) + + it("preserves roles across a multi-turn structured input", () => { + const result = extractResponsesInput([ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Hello!" }, + { role: "user", content: [{ type: "input_text", text: "How are you?" }] }, + ]) + + expect(result.text).toBe("Hi\nHello!\nHow are you?") + expect(result.messages).toEqual([ + { role: "user", content: "Hi" }, + { role: "assistant", content: "Hello!" }, + { role: "user", content: "How are you?" }, + ]) + }) + + it("normalizes unknown or developer roles to user", () => { + const result = extractResponsesInput([ + { role: "developer", content: "system-ish note" }, + ]) + + expect(result.messages).toEqual([ + { role: "user", content: "system-ish note" }, + ]) + }) + + it("skips items that carry no text", () => { + const result = extractResponsesInput([ + { role: "user", content: [{ type: "input_image", image_url: "x" }] }, + { role: "user", content: "actual question" }, + ]) + + expect(result.text).toBe("actual question") + expect(result.messages).toEqual([ + { role: "user", content: "actual question" }, + ]) + }) +})