diff --git a/.changeset/plain-message-identifiers.md b/.changeset/plain-message-identifiers.md new file mode 100644 index 000000000..5988470e4 --- /dev/null +++ b/.changeset/plain-message-identifiers.md @@ -0,0 +1,5 @@ +--- +"@voltagent/core": patch +--- + +Use dictionaries without inherited keys for in-memory message storage so user and conversation identifiers such as constructor, toString and __proto__ do not break message operations or user isolation, including after clearing storage. diff --git a/packages/core/src/memory/adapters/storage/in-memory-identifiers.spec.ts b/packages/core/src/memory/adapters/storage/in-memory-identifiers.spec.ts new file mode 100644 index 000000000..2beaf3d13 --- /dev/null +++ b/packages/core/src/memory/adapters/storage/in-memory-identifiers.spec.ts @@ -0,0 +1,52 @@ +import type { UIMessage } from "ai"; +import { describe, expect, it } from "vitest"; +import { InMemoryStorageAdapter } from "./in-memory"; + +const message: UIMessage = { + id: "message", + role: "user", + parts: [{ type: "text", text: "Hello" }], +}; + +describe("InMemoryStorageAdapter string identifiers", () => { + it.each(["constructor", "toString", "__proto__"])( + "keeps messages isolated when a user identifier is %s", + async (userId) => { + const adapter = new InMemoryStorageAdapter(); + expect(await adapter.getMessages(userId, "constructor")).toEqual([]); + await adapter.addMessage(message, userId, "conversation"); + expect(await adapter.getMessages(userId, "conversation")).toHaveLength(1); + expect(await adapter.getMessages("another-user", "conversation")).toEqual([]); + expect(adapter.getStats()).toMatchObject({ totalUsers: 1, totalMessages: 1 }); + + await adapter.clearMessages(userId); + await adapter.addMessage(message, userId, "conversation"); + expect(await adapter.getMessages(userId, "conversation")).toHaveLength(1); + adapter.clear(); + await adapter.addMessage(message, userId, "conversation"); + expect(adapter.getStats()).toMatchObject({ totalUsers: 1, totalMessages: 1 }); + }, + ); + + it.each(["constructor", "toString", "__proto__"])( + "supports a conversation identifier named %s through message CRUD", + async (conversationId) => { + const adapter = new InMemoryStorageAdapter(); + expect(await adapter.getMessages("user", conversationId)).toEqual([]); + await adapter.deleteMessages([message.id], "user", conversationId); + await adapter.addMessage(message, "user", conversationId); + expect(await adapter.getMessages("user", conversationId)).toHaveLength(1); + await adapter.deleteMessages([message.id], "user", conversationId); + expect(await adapter.getMessages("user", conversationId)).toEqual([]); + await adapter.addMessage(message, "user", conversationId); + await adapter.clearMessages("user", conversationId); + expect(await adapter.getMessages("user", conversationId)).toEqual([]); + await adapter.clearMessages("user"); + await adapter.addMessage(message, "user", conversationId); + expect(await adapter.getMessages("user", conversationId)).toHaveLength(1); + adapter.clear(); + await adapter.addMessage(message, "user", conversationId); + expect(await adapter.getMessages("user", conversationId)).toHaveLength(1); + }, + ); +}); diff --git a/packages/core/src/memory/adapters/storage/in-memory.ts b/packages/core/src/memory/adapters/storage/in-memory.ts index 76dccd800..0fb0281c3 100644 --- a/packages/core/src/memory/adapters/storage/in-memory.ts +++ b/packages/core/src/memory/adapters/storage/in-memory.ts @@ -84,7 +84,7 @@ function areMetadataValuesEqual(left: unknown, right: unknown): boolean { * Simple implementation for testing and development */ export class InMemoryStorageAdapter implements StorageAdapter { - private storage: Record> = {}; + private storage: Record> = Object.create(null); private conversations: Map = new Map(); private users: Map = new Map(); private workflowStates: Map = new Map(); @@ -106,7 +106,7 @@ export class InMemoryStorageAdapter implements StorageAdapter { ): Promise { // Create user's messages container if it doesn't exist if (!this.storage[userId]) { - this.storage[userId] = {}; + this.storage[userId] = Object.create(null); } // Create conversation's messages array if it doesn't exist @@ -165,7 +165,8 @@ export class InMemoryStorageAdapter implements StorageAdapter { const { limit, before, after, roles } = options || {}; // Get user's messages or return empty array - const userMessages = this.storage[userId] || {}; + const userMessages: Record = + this.storage[userId] || Object.create(null); let messages = userMessages[conversationId] || []; // Apply role filter if provided @@ -299,7 +300,7 @@ export class InMemoryStorageAdapter implements StorageAdapter { } } else { // Clear all messages for the user - this.storage[userId] = {}; + this.storage[userId] = Object.create(null); this.conversationSteps.delete(userId); } } @@ -724,7 +725,7 @@ export class InMemoryStorageAdapter implements StorageAdapter { * Clear all data */ clear(): void { - this.storage = {}; + this.storage = Object.create(null); this.conversations.clear(); this.users.clear(); this.workflowStates.clear();