mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-26 00:43:07 +00:00
feat(tools): SDK-level cross-source memory deduplication
Move profile deduplication into the SDK middleware. Facts are normalized (strip leading date, trim, collapse whitespace, casefold) and deduplicated in static > dynamic > search priority within each request, then injected as one owned <supermemory> block that replaces the previous block instead of accumulating. Dedup is mode-aware so query-mode search results are not dropped against an omitted profile. No global/browser Set: request-local only, safe for concurrent requests and Cloudflare Workers. Covers AI SDK, OpenAI Chat/Responses, Mastra, and VoltAgent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4e04a0d7af
commit
7fa452b6b8
13 changed files with 389 additions and 62 deletions
|
|
@ -8,7 +8,7 @@
|
|||
"dev": "tsdown --watch --ignore-watch .turbo",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "vitest --testTimeout 100000",
|
||||
"test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/mastra/unit.test.ts",
|
||||
"test:unit": "vitest run --testTimeout 100000 src/tools-shared.test.ts src/tool-operations.test.ts src/claude-memory.test.ts test/with-supermemory/unit.test.ts test/with-supermemory/conversation-conversion.test.ts test/openai-middleware.unit.test.ts test/mastra/unit.test.ts test/voltagent.unit.test.ts",
|
||||
"test:watch": "vitest --watch --testTimeout 100000"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
MemoryCache,
|
||||
buildMemoriesText,
|
||||
extractQueryText,
|
||||
wrapMemoryContext,
|
||||
type Logger,
|
||||
type MemoryMode,
|
||||
type PromptTemplate,
|
||||
|
|
@ -163,7 +164,7 @@ export class SupermemoryInputProcessor implements Processor {
|
|||
const cachedMemories = this.ctx.memoryCache.get(turnKey)
|
||||
if (cachedMemories) {
|
||||
this.ctx.logger.debug("Using cached memories", { turnKey })
|
||||
messageList.addSystem(cachedMemories, "supermemory")
|
||||
messageList.addSystem(wrapMemoryContext(cachedMemories), "supermemory")
|
||||
return messageList
|
||||
}
|
||||
|
||||
|
|
@ -185,7 +186,7 @@ export class SupermemoryInputProcessor implements Processor {
|
|||
|
||||
if (memories) {
|
||||
this.ctx.memoryCache.set(turnKey, memories)
|
||||
messageList.addSystem(memories, "supermemory")
|
||||
messageList.addSystem(wrapMemoryContext(memories), "supermemory")
|
||||
this.ctx.logger.debug("Injected memories into system prompt", {
|
||||
length: memories.length,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import type OpenAI from "openai"
|
||||
import Supermemory from "supermemory"
|
||||
import { addConversation } from "../conversations-client"
|
||||
import {
|
||||
replaceMemoryContext,
|
||||
stripMemoryContext,
|
||||
wrapMemoryContext,
|
||||
} from "../shared"
|
||||
import { deduplicateMemoriesForMode } from "../tools-shared"
|
||||
import { createLogger, type Logger } from "../vercel/logger"
|
||||
import { convertProfileToMarkdown } from "../vercel/util"
|
||||
|
|
@ -240,18 +245,26 @@ const addSystemPrompt = async (
|
|||
}
|
||||
|
||||
if (systemPromptExists) {
|
||||
logger.debug("Added memories to existing system prompt")
|
||||
return messages.map((msg) =>
|
||||
msg.role === "system"
|
||||
? { ...msg, content: `${msg.content} \n ${memories}` }
|
||||
: msg,
|
||||
)
|
||||
logger.debug("Replaced Supermemory context in existing system prompt")
|
||||
let injected = false
|
||||
return messages.map((msg) => {
|
||||
if (msg.role !== "system") return msg
|
||||
const content = typeof msg.content === "string" ? msg.content : ""
|
||||
if (!injected) {
|
||||
injected = true
|
||||
return { ...msg, content: replaceMemoryContext(content, memories) }
|
||||
}
|
||||
return { ...msg, content: stripMemoryContext(content) }
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"System prompt does not exist, created system prompt with memories",
|
||||
)
|
||||
return [{ role: "system" as const, content: memories }, ...messages]
|
||||
const memoryContext = wrapMemoryContext(memories)
|
||||
return memoryContext
|
||||
? [{ role: "system" as const, content: memoryContext }, ...messages]
|
||||
: messages
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -590,9 +603,10 @@ export function createOpenAIMiddleware(
|
|||
const results = await Promise.all(operations)
|
||||
const memories = results[results.length - 1] // Memory search result is always last
|
||||
|
||||
const enhancedInstructions = memories
|
||||
? `${params.instructions || ""}\n\n${memories}`.trim()
|
||||
: params.instructions
|
||||
const enhancedInstructions = replaceMemoryContext(
|
||||
params.instructions || "",
|
||||
typeof memories === "string" ? memories : "",
|
||||
)
|
||||
|
||||
return originalResponsesCreate.call(
|
||||
openaiClient.responses,
|
||||
|
|
@ -658,7 +672,9 @@ export function createOpenAIMiddleware(
|
|||
)
|
||||
|
||||
const results = await Promise.all(operations)
|
||||
const enhancedMessages = results[results.length - 1] // Enhanced messages result is always last
|
||||
const enhancedMessages = results[
|
||||
results.length - 1
|
||||
] as OpenAI.Chat.Completions.ChatCompletionMessageParam[] // Enhanced messages result is always last
|
||||
|
||||
return originalCreate.call(
|
||||
openaiClient.chat.completions,
|
||||
|
|
|
|||
|
|
@ -40,3 +40,12 @@ export {
|
|||
type BuildMemoriesTextOptions,
|
||||
type GenericMessage,
|
||||
} from "./memory-client"
|
||||
|
||||
// SDK-owned prompt context
|
||||
export {
|
||||
MEMORY_CONTEXT_START,
|
||||
MEMORY_CONTEXT_END,
|
||||
stripMemoryContext,
|
||||
wrapMemoryContext,
|
||||
replaceMemoryContext,
|
||||
} from "./memory-context"
|
||||
|
|
|
|||
32
packages/tools/src/shared/memory-context.ts
Normal file
32
packages/tools/src/shared/memory-context.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export const MEMORY_CONTEXT_START =
|
||||
'<supermemory context="user-memories" readonly>'
|
||||
export const MEMORY_CONTEXT_END = "</supermemory>"
|
||||
|
||||
const MEMORY_CONTEXT_PATTERN =
|
||||
/[ \t]*<supermemory context="user-memories" readonly>[\s\S]*?<\/supermemory>[ \t]*/g
|
||||
|
||||
/** Remove every context block previously owned by the Supermemory middleware. */
|
||||
export function stripMemoryContext(content: string): string {
|
||||
return content
|
||||
.replace(MEMORY_CONTEXT_PATTERN, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Mark retrieved memory context so a later turn can replace it safely. */
|
||||
export function wrapMemoryContext(memories: string): string {
|
||||
const normalized = memories.trim()
|
||||
if (!normalized) return ""
|
||||
return `${MEMORY_CONTEXT_START}\n${normalized}\n${MEMORY_CONTEXT_END}`
|
||||
}
|
||||
|
||||
/** Replace prior middleware context while preserving caller-authored instructions. */
|
||||
export function replaceMemoryContext(
|
||||
content: string,
|
||||
memories: string,
|
||||
): string {
|
||||
const preserved = stripMemoryContext(content)
|
||||
const memoryContext = wrapMemoryContext(memories)
|
||||
if (!memoryContext) return preserved
|
||||
return preserved ? `${preserved}\n\n${memoryContext}` : memoryContext
|
||||
}
|
||||
|
|
@ -56,6 +56,23 @@ describe("deduplicateMemoriesForMode", () => {
|
|||
expect(deduplicated.searchResults).toEqual(["User likes TypeScript"])
|
||||
})
|
||||
|
||||
it("deduplicates normalized fact variants within and across sources", () => {
|
||||
const deduplicated = deduplicateMemoriesForMode("full", {
|
||||
static: [
|
||||
{ memory: "User likes TypeScript" },
|
||||
{ memory: " user likes typescript " },
|
||||
],
|
||||
dynamic: [{ memory: "[2026-08-10] USER LIKES TYPESCRIPT" }],
|
||||
searchResults: [{ memory: "User prefers async/await" }],
|
||||
})
|
||||
|
||||
expect(deduplicated).toEqual({
|
||||
static: ["User likes TypeScript"],
|
||||
dynamic: [],
|
||||
searchResults: ["User prefers async/await"],
|
||||
})
|
||||
})
|
||||
|
||||
it("deduplicates search results against the profile in full mode", () => {
|
||||
const deduplicated = deduplicateMemoriesForMode("full", {
|
||||
static: [{ memory: "User is allergic to peanuts" }],
|
||||
|
|
|
|||
|
|
@ -288,6 +288,15 @@ export interface DeduplicatedMemories {
|
|||
searchResults: string[]
|
||||
}
|
||||
|
||||
/** Normalize exact fact variants without attempting semantic/fuzzy matching. */
|
||||
export function normalizeMemoryFact(memory: string): string {
|
||||
return memory
|
||||
.replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates memory items across static, dynamic, and search result sources.
|
||||
* Priority: Static > Dynamic > Search Results
|
||||
|
|
@ -334,9 +343,10 @@ export function deduplicateMemories(
|
|||
|
||||
for (const item of staticItems as Array<MemoryItem | string>) {
|
||||
const memory = getMemoryString(item)
|
||||
if (memory !== null) {
|
||||
const key = memory === null ? null : normalizeMemoryFact(memory)
|
||||
if (memory !== null && key !== null && !seenMemories.has(key)) {
|
||||
staticMemories.push(memory)
|
||||
seenMemories.add(memory)
|
||||
seenMemories.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -344,9 +354,10 @@ export function deduplicateMemories(
|
|||
|
||||
for (const item of dynamicItems as Array<MemoryItem | string>) {
|
||||
const memory = getMemoryString(item)
|
||||
if (memory !== null && !seenMemories.has(memory)) {
|
||||
const key = memory === null ? null : normalizeMemoryFact(memory)
|
||||
if (memory !== null && key !== null && !seenMemories.has(key)) {
|
||||
dynamicMemories.push(memory)
|
||||
seenMemories.add(memory)
|
||||
seenMemories.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -354,9 +365,10 @@ export function deduplicateMemories(
|
|||
|
||||
for (const item of searchItems as Array<MemoryItem | string>) {
|
||||
const memory = getMemoryString(item)
|
||||
if (memory !== null && !seenMemories.has(memory)) {
|
||||
const key = memory === null ? null : normalizeMemoryFact(memory)
|
||||
if (memory !== null && key !== null && !seenMemories.has(key)) {
|
||||
searchMemories.push(memory)
|
||||
seenMemories.add(memory)
|
||||
seenMemories.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ export {
|
|||
type BuildMemoriesTextOptions,
|
||||
} from "../shared"
|
||||
|
||||
import type { Logger, MemoryPromptData } from "../shared"
|
||||
import {
|
||||
type Logger,
|
||||
type MemoryPromptData,
|
||||
replaceMemoryContext,
|
||||
stripMemoryContext,
|
||||
wrapMemoryContext,
|
||||
} from "../shared"
|
||||
import type { LanguageModelCallOptions } from "./util"
|
||||
|
||||
/**
|
||||
|
|
@ -66,21 +72,28 @@ export const injectMemoriesIntoParams = (
|
|||
)
|
||||
|
||||
if (systemPromptExists) {
|
||||
logger.debug("Added memories to existing system prompt")
|
||||
logger.debug("Replaced Supermemory context in existing system prompt")
|
||||
let injected = false
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Union type compatibility between V2 and V3 prompt types
|
||||
const newPrompt = params.prompt.map((prompt: any) =>
|
||||
prompt.role === "system"
|
||||
? { ...prompt, content: `${prompt.content} \n ${memories}` }
|
||||
: prompt,
|
||||
)
|
||||
const newPrompt = params.prompt.map((prompt: any) => {
|
||||
if (prompt.role !== "system") return prompt
|
||||
const content = String(prompt.content ?? "")
|
||||
if (!injected) {
|
||||
injected = true
|
||||
return { ...prompt, content: replaceMemoryContext(content, memories) }
|
||||
}
|
||||
return { ...prompt, content: stripMemoryContext(content) }
|
||||
})
|
||||
return { ...params, prompt: newPrompt } as LanguageModelCallOptions
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"System prompt does not exist, created system prompt with memories",
|
||||
)
|
||||
const memoryContext = wrapMemoryContext(memories)
|
||||
if (!memoryContext) return params
|
||||
const newPrompt = [
|
||||
{ role: "system" as const, content: memories },
|
||||
{ role: "system" as const, content: memoryContext },
|
||||
...params.prompt,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Union type compatibility between V2 and V3 prompt types
|
||||
] as any
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import {
|
|||
MemoryCache,
|
||||
buildMemoriesText,
|
||||
extractQueryText,
|
||||
replaceMemoryContext,
|
||||
stripMemoryContext,
|
||||
wrapMemoryContext,
|
||||
type Logger,
|
||||
type MemoryMode,
|
||||
} from "../shared"
|
||||
|
|
@ -335,47 +338,42 @@ const injectMemoriesIntoMessages = (
|
|||
memories: string,
|
||||
logger: Logger,
|
||||
): VoltAgentMessage[] => {
|
||||
const systemMessageIndex = messages.findIndex((msg) => msg.role === "system")
|
||||
|
||||
if (systemMessageIndex !== -1) {
|
||||
logger.debug("Added memories to existing system message")
|
||||
const newMessages = [...messages]
|
||||
const systemMessage = newMessages[systemMessageIndex]
|
||||
if (!systemMessage) {
|
||||
return messages
|
||||
}
|
||||
|
||||
// Extract existing text from parts (UIMessage format) or content fallback
|
||||
const parts = (
|
||||
systemMessage as { parts?: Array<{ type: string; text?: string }> }
|
||||
).parts
|
||||
const existingContent = parts
|
||||
? parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text || "")
|
||||
.join("\n")
|
||||
: typeof systemMessage.content === "string"
|
||||
? systemMessage.content
|
||||
: ""
|
||||
|
||||
const newContent = `${existingContent}\n\n${memories}`
|
||||
|
||||
newMessages[systemMessageIndex] = {
|
||||
...systemMessage,
|
||||
content: newContent,
|
||||
// Update parts array to match - this is what the LLM actually reads
|
||||
parts: [{ type: "text", text: newContent }],
|
||||
} as VoltAgentMessage
|
||||
return newMessages
|
||||
if (messages.some((msg) => msg.role === "system")) {
|
||||
logger.debug("Replaced Supermemory context in existing system message")
|
||||
let injected = false
|
||||
return messages.map((message) => {
|
||||
if (message.role !== "system") return message
|
||||
const parts = (
|
||||
message as { parts?: Array<{ type: string; text?: string }> }
|
||||
).parts
|
||||
const partContent = parts
|
||||
?.filter((part) => part.type === "text")
|
||||
.map((part) => part.text || "")
|
||||
.join("\n")
|
||||
const existingContent =
|
||||
partContent ||
|
||||
(typeof message.content === "string" ? message.content : "")
|
||||
const newContent = !injected
|
||||
? replaceMemoryContext(existingContent, memories)
|
||||
: stripMemoryContext(existingContent)
|
||||
injected = true
|
||||
return {
|
||||
...message,
|
||||
content: newContent,
|
||||
parts: [{ type: "text", text: newContent }],
|
||||
} as VoltAgentMessage
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug("Created system message with memories")
|
||||
const memoryContext = wrapMemoryContext(memories)
|
||||
if (!memoryContext) return messages
|
||||
return [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "system" as const,
|
||||
content: memories,
|
||||
parts: [{ type: "text", text: memories }],
|
||||
content: memoryContext,
|
||||
parts: [{ type: "text", text: memoryContext }],
|
||||
} as VoltAgentMessage,
|
||||
...messages,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -198,6 +198,9 @@ describe("SupermemoryInputProcessor", () => {
|
|||
const systemCall = messageList.calls.find((c) => c.method === "addSystem")
|
||||
expect(systemCall).toBeDefined()
|
||||
expect(systemCall?.args[0]).toContain("TypeScript")
|
||||
expect(systemCall?.args[0]).toContain(
|
||||
'<supermemory context="user-memories" readonly>',
|
||||
)
|
||||
expect(systemCall?.args[1]).toBe("supermemory")
|
||||
})
|
||||
|
||||
|
|
|
|||
61
packages/tools/test/openai-middleware.unit.test.ts
Normal file
61
packages/tools/test/openai-middleware.unit.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type OpenAI from "openai"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { withSupermemory } from "../src/openai"
|
||||
|
||||
describe("OpenAI middleware memory context", () => {
|
||||
const originalApiKey = process.env.SUPERMEMORY_API_KEY
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.SUPERMEMORY_API_KEY = "sm_test_key"
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalApiKey === undefined) delete process.env.SUPERMEMORY_API_KEY
|
||||
else process.env.SUPERMEMORY_API_KEY = originalApiKey
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it("replaces prior SDK context in chat system messages", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
profile: { static: [{ memory: "Fresh profile fact" }], dynamic: [] },
|
||||
searchResults: { results: [] },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const originalCreate = vi.fn().mockResolvedValue({ choices: [] })
|
||||
const client = {
|
||||
chat: { completions: { create: originalCreate } },
|
||||
} as unknown as OpenAI
|
||||
const wrapped = withSupermemory(client, {
|
||||
containerTag: "user-a",
|
||||
customId: "conversation-a",
|
||||
mode: "profile",
|
||||
addMemory: "never",
|
||||
})
|
||||
|
||||
await wrapped.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
'Be helpful.\n\n<supermemory context="user-memories" readonly>\nStale profile fact\n</supermemory>',
|
||||
},
|
||||
{ role: "user", content: "What do you remember?" },
|
||||
],
|
||||
})
|
||||
|
||||
const forwarded = originalCreate.mock.calls[0]?.[0]
|
||||
const content = String(forwarded.messages[0].content)
|
||||
expect(content).toContain("Be helpful.")
|
||||
expect(content).toContain("Fresh profile fact")
|
||||
expect(content).not.toContain("Stale profile fact")
|
||||
expect(
|
||||
content.match(/<supermemory context="user-memories" readonly>/g),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
50
packages/tools/test/voltagent.unit.test.ts
Normal file
50
packages/tools/test/voltagent.unit.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { createSupermemoryHooks } from "../src/voltagent"
|
||||
|
||||
describe("VoltAgent memory context", () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it("replaces prior SDK context in the prepared system message", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
profile: { static: [{ memory: "Fresh profile fact" }], dynamic: [] },
|
||||
searchResults: { results: [] },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const hooks = createSupermemoryHooks("user-a", {
|
||||
customId: "conversation-a",
|
||||
apiKey: "sm_test_key",
|
||||
mode: "profile",
|
||||
addMemory: "never",
|
||||
})
|
||||
|
||||
const args = {
|
||||
agent: { name: "test-agent" },
|
||||
context: {
|
||||
input: { messages: [{ role: "user", content: "Remember me" }] },
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: "system",
|
||||
role: "system",
|
||||
content:
|
||||
'Be helpful.\n\n<supermemory context="user-memories" readonly>\nStale profile fact\n</supermemory>',
|
||||
parts: [],
|
||||
},
|
||||
],
|
||||
} as Parameters<NonNullable<typeof hooks.onPrepareMessages>>[0]
|
||||
const result = await hooks.onPrepareMessages?.(args)
|
||||
|
||||
const content = String(result?.messages?.[0]?.content ?? "")
|
||||
expect(content).toContain("Be helpful.")
|
||||
expect(content).toContain("Fresh profile fact")
|
||||
expect(content).not.toContain("Stale profile fact")
|
||||
expect(
|
||||
content.match(/<supermemory context="user-memories" readonly>/g),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -301,6 +301,121 @@ describe("Unit: withSupermemory", () => {
|
|||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(result2.prompt[0]?.content).toContain("Memory from call 2")
|
||||
})
|
||||
|
||||
it("replaces the prior SDK memory block instead of accumulating context", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve(createMockProfileResponse(["Fresh profile fact"])),
|
||||
})
|
||||
|
||||
const inner = createMockLanguageModel()
|
||||
vi.mocked(inner.doGenerate).mockResolvedValue({
|
||||
content: [{ type: "text", text: "Done" }],
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||
warnings: [],
|
||||
})
|
||||
const wrapped = withSupermemory(inner, {
|
||||
containerTag: TEST_CONFIG.containerTag,
|
||||
customId: "conversation-a",
|
||||
mode: "profile",
|
||||
addMemory: "never",
|
||||
apiKey: TEST_CONFIG.apiKey,
|
||||
})
|
||||
|
||||
await wrapped.doGenerate({
|
||||
prompt: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
'Be helpful.\n\n<supermemory context="user-memories" readonly>\nStale profile fact\n</supermemory>',
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "What do you remember?" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const forwarded = vi.mocked(inner.doGenerate).mock.calls[0]?.[0]
|
||||
const system = forwarded?.prompt.find(
|
||||
(message) => message.role === "system",
|
||||
)
|
||||
const content = String(system?.content ?? "")
|
||||
|
||||
expect(content).toContain("Be helpful.")
|
||||
expect(content).toContain("Fresh profile fact")
|
||||
expect(content).not.toContain("Stale profile fact")
|
||||
expect(
|
||||
content.match(/<supermemory context="user-memories" readonly>/g),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("keeps concurrent user contexts isolated", async () => {
|
||||
fetchMock.mockImplementation(async (_url, init) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"))
|
||||
return {
|
||||
ok: true,
|
||||
json: async () =>
|
||||
createMockProfileResponse([
|
||||
body.containerTag === "user-a"
|
||||
? "Fact for Alice"
|
||||
: "Fact for Bob",
|
||||
]),
|
||||
}
|
||||
})
|
||||
const innerA = createMockLanguageModel()
|
||||
const innerB = createMockLanguageModel()
|
||||
vi.mocked(innerA.doGenerate).mockResolvedValue({
|
||||
content: [{ type: "text", text: "A" }],
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||
warnings: [],
|
||||
})
|
||||
vi.mocked(innerB.doGenerate).mockResolvedValue({
|
||||
content: [{ type: "text", text: "B" }],
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||
warnings: [],
|
||||
})
|
||||
const wrappedA = withSupermemory(innerA, {
|
||||
containerTag: "user-a",
|
||||
customId: "conversation-a",
|
||||
apiKey: TEST_CONFIG.apiKey,
|
||||
addMemory: "never",
|
||||
})
|
||||
const wrappedB = withSupermemory(innerB, {
|
||||
containerTag: "user-b",
|
||||
customId: "conversation-b",
|
||||
apiKey: TEST_CONFIG.apiKey,
|
||||
addMemory: "never",
|
||||
})
|
||||
const params = {
|
||||
prompt: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "Remember me" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
wrappedA.doGenerate(params),
|
||||
wrappedB.doGenerate(params),
|
||||
])
|
||||
|
||||
const promptA = String(
|
||||
vi.mocked(innerA.doGenerate).mock.calls[0]?.[0].prompt[0]?.content,
|
||||
)
|
||||
const promptB = String(
|
||||
vi.mocked(innerB.doGenerate).mock.calls[0]?.[0].prompt[0]?.content,
|
||||
)
|
||||
expect(promptA).toContain("Fact for Alice")
|
||||
expect(promptA).not.toContain("Fact for Bob")
|
||||
expect(promptB).toContain("Fact for Bob")
|
||||
expect(promptB).not.toContain("Fact for Alice")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue