This commit is contained in:
Abhay Singh 2026-08-30 23:37:29 +05:30 • committed by GitHub
commit 985ec79e1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 191 additions and 4 deletions

View file

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

View file

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