supermemory/packages/tools/test/mastra/integration.test.ts
Dhravya d0f53b0d64
feat(tools): SDK-level cross-source memory deduplication (#1531)
## Stack Context

This stack moves memory deduplication **out of the playground UI and into the SDKs themselves**, so every integration injects a single, deduplicated, self-replacing memory block. Three PRs:

1. **`sdk-dedup/tools-ts`** (this PR) — TypeScript SDK core + integrations
2. `sdk-dedup/python` — Python SDKs
3. `sdk-dedup/playground` — playground debug view reflects the SDK-owned block

## What?

Move profile deduplication into the SDK middleware for the TypeScript tools package.

- Facts are normalized (strip leading `[YYYY-MM-DD]`, trim, collapse whitespace, casefold) and deduplicated in **`static > dynamic > search`** priority within a single request.
- The result is injected as one **owned `<supermemory>` block** that *replaces* the previous block instead of accumulating a new one each turn.
- Dedup is **mode-aware**: in query mode, search results are not dropped against a profile that isn't being injected.
- Deduplication is **request-local** — no global/browser `Set`. Safe for multiple users, concurrent requests, and Cloudflare Worker isolates.

Covers AI SDK, OpenAI (Chat + Responses), Mastra, and VoltAgent. New `shared/memory-context.ts` owns the block-replacement logic.

## Why?

The earlier "conversation-scoped deduplication" was only a playground browser `Set` — a UI debug affordance that did not change what the SDK sent to the model, and would have been unsafe as server-side global state. Real cross-source dedup belongs in the SDK, applied fresh per stateless model request.

## Testing

- `bun run test` in `packages/tools`: 145 passed (the one failing suite, `claude-memory.test.ts`, is a pre-existing broken import unrelated to this change).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes how system prompts and instructions are built across all TypeScript integrations; behavior is well-covered by unit tests but incorrect strip/replace logic could drop or duplicate context in production prompts.
>
> **Overview**
> Moves **cross-source memory deduplication** and **owned prompt injection** into `@supermemory/tools` so every integration sends one deduplicated memory block per request instead of growing context each turn.
>
> **Deduplication:** Facts are normalized via `normalizeMemoryFact` (strip `[YYYY-MM-DD]`, trim, collapse whitespace, lowercase) and deduplicated with **static → dynamic → search** priority. `deduplicateMemoriesForMode` keeps search hits in **query** mode when the profile is not injected.
>
> **Owned `<supermemory>` block:** New `shared/memory-context.ts` wraps memories in `<supermemory context="user-memories" readonly>`, strips stale blocks, and **replaces** prior SDK context while preserving caller system instructions. Applied in AI SDK (`injectMemoriesIntoParams`), OpenAI Chat/Responses middleware, Mastra input processor (`wrapMemoryContext`), and VoltAgent hooks.
>
> **Tests:** Unit coverage for block replacement (with-supermemory, OpenAI, VoltAgent), Mastra wrapper tag assertion, normalized dedup variants, and concurrent `containerTag` isolation.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2fa2e0d85c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-09-01 06:10:36 +00:00

626 lines
18 KiB
TypeScript

/**
* Integration tests for the Mastra integration
* Tests processors and wrapper with real Supermemory API calls
*/
import { describe, it, expect, vi } from "vitest"
import {
RequestContext,
MASTRA_THREAD_ID_KEY,
} from "@mastra/core/request-context"
import {
SupermemoryInputProcessor,
SupermemoryOutputProcessor,
createSupermemoryProcessors,
withSupermemory,
} from "../../src/mastra"
import type {
ProcessInputArgs,
ProcessOutputResultArgs,
MessageList,
MastraDBMessage,
MastraMessageContentV2,
Processor,
} from "../../src/mastra"
interface MockAgentConfig {
id: string
name?: string
model?: string
inputProcessors?: Processor[]
outputProcessors?: Processor[]
[key: string]: unknown
}
import "dotenv/config"
const INTEGRATION_CONFIG = {
apiKey: process.env.SUPERMEMORY_API_KEY || "",
baseUrl: process.env.SUPERMEMORY_BASE_URL || "https://api.supermemory.ai",
containerTag: "integration-test-mastra",
customId: "integration-test-conversation",
}
const shouldRunIntegration = !!process.env.SUPERMEMORY_API_KEY
/**
* Helper to create MastraMessageContentV2 from text
*/
function createMessageContent(text: string): MastraMessageContentV2 {
return {
format: 2,
parts: [{ type: "text", text }],
}
}
/**
* Helper to create a MastraDBMessage
*/
function createMessage(
role: "user" | "assistant" | "system",
text: string,
): MastraDBMessage {
return {
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2)}`,
role,
content: createMessageContent(text),
createdAt: new Date(),
}
}
/**
* Creates a mock MessageList that captures calls for assertion.
*/
const createIntegrationMessageList = (): MessageList & {
calls: { method: string; args: unknown[] }[]
getSystemContent: () => string | undefined
} => {
const calls: { method: string; args: unknown[] }[] = []
return {
calls,
clearSystemMessages: vi.fn(),
addSystem: vi.fn((content: string, id?: string) => {
calls.push({ method: "addSystem", args: [content, id] })
}),
addUser: vi.fn((content: string) => {
calls.push({ method: "addUser", args: [content] })
}),
addAssistant: vi.fn((content: string) => {
calls.push({ method: "addAssistant", args: [content] })
}),
getSystemContent: () => {
const systemCall = calls.find((c) => c.method === "addSystem")
return systemCall?.args[0] as string | undefined
},
} as unknown as MessageList & {
calls: { method: string; args: unknown[] }[]
getSystemContent: () => string | undefined
}
}
describe.skipIf(!shouldRunIntegration)(
"Integration: Mastra processors with real API",
() => {
describe("SupermemoryInputProcessor", () => {
it("should fetch real memories and inject into messageList", async () => {
const processor = new SupermemoryInputProcessor({
containerTag: INTEGRATION_CONFIG.containerTag,
customId: INTEGRATION_CONFIG.customId,
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
mode: "profile",
})
const messageList = createIntegrationMessageList()
const messages: MastraDBMessage[] = [
createMessage("user", "Hello, what do you know about me?"),
]
const args: ProcessInputArgs = {
messages,
systemMessages: [],
messageList,
abort: vi.fn() as never,
retryCount: 0,
}
await processor.processInput(args)
expect(messageList.addSystem).toHaveBeenCalled()
const systemContent = messageList.getSystemContent()
expect(typeof systemContent).toBe("string")
})
it("should use query mode with user message as search query", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch")
const processor = new SupermemoryInputProcessor({
containerTag: INTEGRATION_CONFIG.containerTag,
customId: INTEGRATION_CONFIG.customId,
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
mode: "query",
})
const messageList = createIntegrationMessageList()
const args: ProcessInputArgs = {
messages: [
createMessage(
"user",
"What are my favorite programming languages?",
),
],
systemMessages: [],
messageList,
abort: vi.fn() as never,
retryCount: 0,
}
await processor.processInput(args)
const profileCalls = fetchSpy.mock.calls.filter(
(call) =>
typeof call[0] === "string" && call[0].includes("/v4/profile"),
)
expect(profileCalls.length).toBeGreaterThan(0)
const profileCall = profileCalls[0]
if (profileCall?.[1]) {
const body = JSON.parse(
(profileCall[1] as RequestInit).body as string,
)
expect(body.q).toBe("What are my favorite programming languages?")
}
fetchSpy.mockRestore()
})
it("should use full mode with both profile and query", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch")
const processor = new SupermemoryInputProcessor({
containerTag: INTEGRATION_CONFIG.containerTag,
customId: INTEGRATION_CONFIG.customId,
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
mode: "full",
})
const messageList = createIntegrationMessageList()
const args: ProcessInputArgs = {
messages: [createMessage("user", "Full mode test query")],
systemMessages: [],
messageList,
abort: vi.fn() as never,
retryCount: 0,
}
await processor.processInput(args)
const profileCalls = fetchSpy.mock.calls.filter(
(call) =>
typeof call[0] === "string" && call[0].includes("/v4/profile"),
)
expect(profileCalls.length).toBeGreaterThan(0)
const profileCall = profileCalls[0]
if (profileCall?.[1]) {
const body = JSON.parse(
(profileCall[1] as RequestInit).body as string,
)
expect(body.q).toBe("Full mode test query")
}
fetchSpy.mockRestore()
})
it("should cache memories for repeated calls with same message", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch")
const processor = new SupermemoryInputProcessor({
containerTag: INTEGRATION_CONFIG.containerTag,
customId: INTEGRATION_CONFIG.customId,
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
mode: "profile",
})
const messages: MastraDBMessage[] = [
createMessage("user", "Cache test message"),
]
const args1: ProcessInputArgs = {
messages,
systemMessages: [],
messageList: createIntegrationMessageList(),
abort: vi.fn() as never,
retryCount: 0,
}
await processor.processInput(args1)
const callsAfterFirst = fetchSpy.mock.calls.filter(
(call) =>
typeof call[0] === "string" && call[0].includes("/v4/profile"),
).length
const args2: ProcessInputArgs = {
messages,
systemMessages: [],
messageList: createIntegrationMessageList(),
abort: vi.fn() as never,
retryCount: 0,
}
await processor.processInput(args2)
const callsAfterSecond = fetchSpy.mock.calls.filter(
(call) =>
typeof call[0] === "string" && call[0].includes("/v4/profile"),
).length
expect(callsAfterSecond).toBe(callsAfterFirst)
fetchSpy.mockRestore()
})
it("should use custom promptTemplate for memory formatting", async () => {
const customTemplate = (data: {
userMemories: string
generalSearchMemories: string
}) => `<mastra-memories>${data.userMemories}</mastra-memories>`
const processor = new SupermemoryInputProcessor({
containerTag: INTEGRATION_CONFIG.containerTag,
customId: INTEGRATION_CONFIG.customId,
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
mode: "profile",
promptTemplate: customTemplate,
})
const messageList = createIntegrationMessageList()
const args: ProcessInputArgs = {
messages: [createMessage("user", "Custom template test")],
systemMessages: [],
messageList,
abort: vi.fn() as never,
retryCount: 0,
}
await processor.processInput(args)
const systemContent = messageList.getSystemContent()
expect(systemContent).toMatch(/<mastra-memories>.*<\/mastra-memories>/s)
})
})
describe("SupermemoryOutputProcessor", () => {
it("should save conversation when addMemory is always", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch")
const customId = `test-mastra-${Date.now()}`
const processor = new SupermemoryOutputProcessor({
containerTag: INTEGRATION_CONFIG.containerTag,
customId,
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
addMemory: "always",
})
const args: ProcessOutputResultArgs = {
messages: [
createMessage("user", "Hello from Mastra integration test"),
createMessage("assistant", "Hi! I'm responding to the test."),
],
messageList: createIntegrationMessageList(),
abort: vi.fn() as never,
retryCount: 0,
}
await processor.processOutputResult(args)
const conversationCalls = fetchSpy.mock.calls.filter(
(call) =>
typeof call[0] === "string" &&
call[0].includes("/v4/conversations"),
)
expect(conversationCalls.length).toBeGreaterThan(0)
fetchSpy.mockRestore()
})
it("should not save when addMemory is never", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch")
const processor = new SupermemoryOutputProcessor({
containerTag: INTEGRATION_CONFIG.containerTag,
customId: "test-thread",
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
addMemory: "never",
})
const args: ProcessOutputResultArgs = {
messages: [
createMessage("user", "This should not be saved"),
createMessage("assistant", "Agreed"),
],
messageList: createIntegrationMessageList(),
abort: vi.fn() as never,
retryCount: 0,
}
await processor.processOutputResult(args)
const conversationCalls = fetchSpy.mock.calls.filter(
(call) =>
typeof call[0] === "string" &&
call[0].includes("/v4/conversations"),
)
expect(conversationCalls.length).toBe(0)
fetchSpy.mockRestore()
})
it("should use threadId from RequestContext when available", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch")
const processor = new SupermemoryOutputProcessor({
containerTag: INTEGRATION_CONFIG.containerTag,
customId: INTEGRATION_CONFIG.customId,
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
addMemory: "always",
})
const contextThreadId = `context-thread-${Date.now()}`
const requestContext = new RequestContext()
requestContext.set(MASTRA_THREAD_ID_KEY, contextThreadId)
const args: ProcessOutputResultArgs = {
messages: [
createMessage("user", "Test with RequestContext threadId"),
createMessage("assistant", "Got it!"),
],
messageList: createIntegrationMessageList(),
abort: vi.fn() as never,
retryCount: 0,
requestContext,
}
await processor.processOutputResult(args)
const conversationCalls = fetchSpy.mock.calls.filter(
(call) =>
typeof call[0] === "string" &&
call[0].includes("/v4/conversations"),
)
expect(conversationCalls.length).toBeGreaterThan(0)
fetchSpy.mockRestore()
})
})
describe("createSupermemoryProcessors", () => {
it("should create working input and output processors", async () => {
const { input, output } = createSupermemoryProcessors({
containerTag: INTEGRATION_CONFIG.containerTag,
customId: `processors-test-${Date.now()}`,
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
mode: "profile",
addMemory: "always",
})
const messageList = createIntegrationMessageList()
const inputArgs: ProcessInputArgs = {
messages: [createMessage("user", "Test processors factory")],
systemMessages: [],
messageList,
abort: vi.fn() as never,
retryCount: 0,
}
await input.processInput(inputArgs)
expect(messageList.addSystem).toHaveBeenCalled()
const outputArgs: ProcessOutputResultArgs = {
messages: [
createMessage("user", "Test processors factory"),
createMessage("assistant", "Response"),
],
messageList: createIntegrationMessageList(),
abort: vi.fn() as never,
retryCount: 0,
}
await output.processOutputResult(outputArgs)
})
})
describe("withSupermemory wrapper", () => {
it("should enhance config with working processors", async () => {
const config: MockAgentConfig = {
id: "test-mastra-agent",
name: "Test Mastra Agent",
model: "gpt-4o",
}
const enhanced = withSupermemory(config, {
containerTag: INTEGRATION_CONFIG.containerTag,
customId: `wrapper-test-${Date.now()}`,
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
mode: "profile",
addMemory: "always",
})
expect(enhanced.id).toBe("test-mastra-agent")
expect(enhanced.name).toBe("Test Mastra Agent")
expect(enhanced.model).toBe("gpt-4o")
expect(enhanced.inputProcessors).toHaveLength(1)
expect(enhanced.outputProcessors).toHaveLength(1)
const inputProcessor = enhanced.inputProcessors?.[0]
expect(inputProcessor?.id).toBe("supermemory-input")
if (inputProcessor?.processInput) {
const messageList = createIntegrationMessageList()
const args: ProcessInputArgs = {
messages: [createMessage("user", "Wrapper test")],
systemMessages: [],
messageList,
abort: vi.fn() as never,
retryCount: 0,
}
await inputProcessor.processInput(args)
expect(messageList.addSystem).toHaveBeenCalled()
}
})
it("should merge with existing processors correctly", async () => {
const existingInputProcessor = {
id: "existing-input",
name: "Existing Input Processor",
processInput: vi.fn().mockResolvedValue(undefined),
}
const existingOutputProcessor = {
id: "existing-output",
name: "Existing Output Processor",
processOutputResult: vi.fn().mockResolvedValue(undefined),
}
const config: MockAgentConfig = {
id: "agent-with-processors",
name: "Agent With Processors",
inputProcessors: [existingInputProcessor],
outputProcessors: [existingOutputProcessor],
}
const enhanced = withSupermemory(config, {
containerTag: INTEGRATION_CONFIG.containerTag,
customId: INTEGRATION_CONFIG.customId,
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
mode: "profile",
})
expect(enhanced.inputProcessors).toHaveLength(2)
expect(enhanced.outputProcessors).toHaveLength(2)
expect(enhanced.inputProcessors?.[0]?.id).toBe("supermemory-input")
expect(enhanced.inputProcessors?.[1]?.id).toBe("existing-input")
expect(enhanced.outputProcessors?.[0]?.id).toBe("existing-output")
expect(enhanced.outputProcessors?.[1]?.id).toBe("supermemory-output")
})
})
describe("Options", () => {
it("verbose mode should not break functionality", async () => {
const processor = new SupermemoryInputProcessor({
containerTag: INTEGRATION_CONFIG.containerTag,
customId: INTEGRATION_CONFIG.customId,
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
mode: "profile",
verbose: true,
})
const messageList = createIntegrationMessageList()
const args: ProcessInputArgs = {
messages: [createMessage("user", "Verbose mode test")],
systemMessages: [],
messageList,
abort: vi.fn() as never,
retryCount: 0,
}
await processor.processInput(args)
expect(messageList.addSystem).toHaveBeenCalled()
})
it("custom baseUrl should be used for API calls", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch")
const processor = new SupermemoryInputProcessor({
containerTag: INTEGRATION_CONFIG.containerTag,
customId: INTEGRATION_CONFIG.customId,
apiKey: INTEGRATION_CONFIG.apiKey,
baseUrl: INTEGRATION_CONFIG.baseUrl,
mode: "profile",
})
const args: ProcessInputArgs = {
messages: [createMessage("user", "Base URL test")],
systemMessages: [],
messageList: createIntegrationMessageList(),
abort: vi.fn() as never,
retryCount: 0,
}
await processor.processInput(args)
const profileCalls = fetchSpy.mock.calls.filter(
(call) =>
typeof call[0] === "string" && call[0].includes("/v4/profile"),
)
expect(profileCalls.length).toBeGreaterThan(0)
const url = profileCalls[0]?.[0] as string
expect(url.startsWith(INTEGRATION_CONFIG.baseUrl)).toBe(true)
fetchSpy.mockRestore()
})
})
describe("Error handling", () => {
it("should handle invalid API key gracefully", async () => {
const processor = new SupermemoryInputProcessor({
containerTag: INTEGRATION_CONFIG.containerTag,
customId: INTEGRATION_CONFIG.customId,
apiKey: "invalid-api-key-12345",
baseUrl: INTEGRATION_CONFIG.baseUrl,
mode: "profile",
})
const messageList = createIntegrationMessageList()
const args: ProcessInputArgs = {
messages: [createMessage("user", "Invalid key test")],
systemMessages: [],
messageList,
abort: vi.fn() as never,
retryCount: 0,
}
const result = await processor.processInput(args)
expect(result).toBe(messageList)
expect(messageList.addSystem).not.toHaveBeenCalled()
})
it("output processor should handle save errors gracefully", async () => {
const processor = new SupermemoryOutputProcessor({
containerTag: INTEGRATION_CONFIG.containerTag,
customId: "error-test",
apiKey: "invalid-api-key-12345",
baseUrl: INTEGRATION_CONFIG.baseUrl,
addMemory: "always",
})
const args: ProcessOutputResultArgs = {
messages: [
createMessage("user", "Error test"),
createMessage("assistant", "Response"),
],
messageList: createIntegrationMessageList(),
abort: vi.fn() as never,
retryCount: 0,
}
await expect(processor.processOutputResult(args)).resolves.toBeDefined()
})
})
},
)