diff --git a/packages/tools/src/openai/middleware.ts b/packages/tools/src/openai/middleware.ts index 5ac50402..70a82d5b 100644 --- a/packages/tools/src/openai/middleware.ts +++ b/packages/tools/src/openai/middleware.ts @@ -291,6 +291,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. * @@ -378,7 +457,10 @@ const addMemoryTool = async ( return } - // Fallback to old behavior for non-conversation memories + // 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], @@ -544,7 +626,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") @@ -562,9 +646,22 @@ 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 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, containerTag, content, memoryCustomId, logger), + addMemoryTool( + client, + containerTag, + content, + memoryCustomId, + logger, + 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" }, + ]) + }) +})