Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/seven-bottles-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@voltagent/redis": patch
---

feat: add @voltagent/redis memory storage adapter
52 changes: 52 additions & 0 deletions packages/redis/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@voltagent/redis",
"description": "VoltAgent Redis - Redis Memory provider integration for VoltAgent",
"version": "0.1.0",
"dependencies": {
"@voltagent/internal": "^1.0.2",
"ioredis": "^5.6.1"
},
"devDependencies": {
"@vitest/coverage-v8": "^3.2.4",
"@voltagent/core": "^2.4.4",
"ai": "^6.0.0"
},
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"files": [
"dist"
],
"license": "MIT",
"main": "dist/index.js",
"module": "dist/index.mjs",
"peerDependencies": {
"@voltagent/core": "^2.0.0",
"ai": "^6.0.0"
},
"repository": {
"type": "git",
"url": "https://github.com/VoltAgent/voltagent.git",
"directory": "packages/redis"
},
"scripts": {
"attw": "attw --pack",
"build": "tsup",
"dev": "tsup --watch",
"lint": "biome check .",
"lint:fix": "biome check . --write",
"publint": "publint --strict",
"test": "vitest",
"test:coverage": "vitest run --coverage"
},
"types": "dist/index.d.ts"
}
1 change: 1 addition & 0 deletions packages/redis/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { RedisMemoryAdapter, type RedisMemoryOptions } from "./memory-adapter";
304 changes: 304 additions & 0 deletions packages/redis/src/memory-adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { RedisMemoryAdapter } from "./memory-adapter";

// Mock ioredis
const mockPipeline = {
set: vi.fn().mockReturnThis(),
get: vi.fn().mockReturnThis(),
del: vi.fn().mockReturnThis(),
zadd: vi.fn().mockReturnThis(),
zrem: vi.fn().mockReturnThis(),
sadd: vi.fn().mockReturnThis(),
srem: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue([]),
};

const mockRedis = {
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
exists: vi.fn(),
zadd: vi.fn(),
zrange: vi.fn().mockResolvedValue([]),
zrevrange: vi.fn().mockResolvedValue([]),
zrangebyscore: vi.fn().mockResolvedValue([]),
zrem: vi.fn(),
sadd: vi.fn(),
srem: vi.fn(),
smembers: vi.fn().mockResolvedValue([]),
pipeline: vi.fn(() => mockPipeline),
quit: vi.fn().mockResolvedValue("OK"),
};

vi.mock("ioredis", () => ({
default: vi.fn(() => mockRedis),
}));

describe("RedisMemoryAdapter", () => {
let adapter: RedisMemoryAdapter;

beforeEach(() => {
vi.clearAllMocks();
adapter = new RedisMemoryAdapter({
connection: "redis://localhost:6379",
keyPrefix: "test",
});
});

// ── Conversation tests ───────────────────────────────────────────────

describe("createConversation", () => {
it("creates a conversation and indexes it", async () => {
mockRedis.exists.mockResolvedValue(0);

const result = await adapter.createConversation({
id: "conv-1",
resourceId: "agent-1",
userId: "user-1",
title: "Test Conversation",
metadata: {},
});

expect(result.id).toBe("conv-1");
expect(result.resourceId).toBe("agent-1");
expect(result.userId).toBe("user-1");
expect(result.title).toBe("Test Conversation");
expect(result.createdAt).toBeDefined();

expect(mockPipeline.set).toHaveBeenCalledWith("test:conv:conv-1", expect.any(String));
expect(mockPipeline.zadd).toHaveBeenCalledWith(
"test:convs:resource:agent-1",
expect.any(Number),
"conv-1",
);
expect(mockPipeline.zadd).toHaveBeenCalledWith(
"test:convs:user:user-1",
expect.any(Number),
"conv-1",
);
expect(mockPipeline.exec).toHaveBeenCalled();
});

it("throws ConversationAlreadyExistsError for duplicate IDs", async () => {
mockRedis.exists.mockResolvedValue(1);

await expect(
adapter.createConversation({
id: "conv-1",
resourceId: "agent-1",
userId: "user-1",
title: "Test",
metadata: {},
}),
).rejects.toThrow();
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Error-contract tests are too broad: rejects.toThrow() does not enforce the specific error type the test names claim to validate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/redis/src/memory-adapter.spec.ts, line 93:

<comment>Error-contract tests are too broad: `rejects.toThrow()` does not enforce the specific error type the test names claim to validate.</comment>

<file context>
@@ -0,0 +1,304 @@
+          title: "Test",
+          metadata: {},
+        }),
+      ).rejects.toThrow();
+    });
+  });
</file context>
Fix with Cubic

});
});

describe("getConversation", () => {
it("returns a conversation by ID", async () => {
const conv = {
id: "conv-1",
resourceId: "agent-1",
userId: "user-1",
title: "Test",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
mockRedis.get.mockResolvedValue(JSON.stringify(conv));

const result = await adapter.getConversation("conv-1");
expect(result).toEqual(conv);
expect(mockRedis.get).toHaveBeenCalledWith("test:conv:conv-1");
});

it("returns null for nonexistent conversation", async () => {
mockRedis.get.mockResolvedValue(null);
const result = await adapter.getConversation("nonexistent");
expect(result).toBeNull();
});
});

describe("updateConversation", () => {
it("updates title and updatedAt", async () => {
const existing = {
id: "conv-1",
resourceId: "agent-1",
userId: "user-1",
title: "Old Title",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
mockRedis.get.mockResolvedValue(JSON.stringify(existing));

const result = await adapter.updateConversation("conv-1", { title: "New Title" });
expect(result.title).toBe("New Title");
expect(result.createdAt).toBe(existing.createdAt);
expect(result.updatedAt).not.toBe(existing.updatedAt);
});

it("throws ConversationNotFoundError for missing conversation", async () => {
mockRedis.get.mockResolvedValue(null);
await expect(adapter.updateConversation("nonexistent", { title: "X" })).rejects.toThrow();
});
});

describe("deleteConversation", () => {
it("deletes conversation and all related data", async () => {
const conv = {
id: "conv-1",
resourceId: "agent-1",
userId: "user-1",
title: "Test",
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
mockRedis.get.mockResolvedValue(JSON.stringify(conv));

await adapter.deleteConversation("conv-1");

expect(mockPipeline.del).toHaveBeenCalledWith("test:conv:conv-1");
expect(mockPipeline.del).toHaveBeenCalledWith("test:msgs:conv-1");
expect(mockPipeline.del).toHaveBeenCalledWith("test:steps:conv-1");
expect(mockPipeline.zrem).toHaveBeenCalledWith("test:convs:resource:agent-1", "conv-1");
expect(mockPipeline.zrem).toHaveBeenCalledWith("test:convs:user:user-1", "conv-1");
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: deleteConversation test does not assert pipeline.exec(), so it can pass even if queued Redis mutations are never executed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/redis/src/memory-adapter.spec.ts, line 166:

<comment>`deleteConversation` test does not assert `pipeline.exec()`, so it can pass even if queued Redis mutations are never executed.</comment>

<file context>
@@ -0,0 +1,304 @@
+      expect(mockPipeline.del).toHaveBeenCalledWith("test:msgs:conv-1");
+      expect(mockPipeline.del).toHaveBeenCalledWith("test:steps:conv-1");
+      expect(mockPipeline.zrem).toHaveBeenCalledWith("test:convs:resource:agent-1", "conv-1");
+      expect(mockPipeline.zrem).toHaveBeenCalledWith("test:convs:user:user-1", "conv-1");
+    });
+  });
</file context>
Fix with Cubic

});
});

// ── Message tests ────────────────────────────────────────────────────

describe("addMessage", () => {
it("adds a message to the conversation sorted set", async () => {
await adapter.addMessage(
{ id: "msg-1", role: "user", parts: [{ type: "text", text: "hello" }] } as UIMessage,
"user-1",
"conv-1",
);

expect(mockRedis.zadd).toHaveBeenCalledWith(
"test:msgs:conv-1",
expect.any(Number),
expect.stringContaining("msg-1"),
);
});
});
Comment on lines +173 to +186
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing UIMessage type import.

UIMessage is used on line 175 but is not imported. This will cause a TypeScript compilation error.

🔧 Proposed fix
 import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { UIMessage } from "ai";
 import { RedisMemoryAdapter } from "./memory-adapter";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/redis/src/memory-adapter.spec.ts` around lines 173 - 186, The test
uses the UIMessage type in the "adds a message to the conversation sorted set"
spec (the object passed to adapter.addMessage) but UIMessage isn't imported; add
a type import for UIMessage from the module that exports it (use an import type
{ UIMessage } ... at the top of memory-adapter.spec.ts) so the file compiles and
the adapter.addMessage call keeps its typed argument.


describe("getMessages", () => {
it("returns messages from the sorted set", async () => {
const msg = {
id: "msg-1",
role: "user",
parts: [{ type: "text", text: "hi" }],
createdAt: "2026-01-01T00:00:00.000Z",
};
mockRedis.zrange.mockResolvedValue([JSON.stringify(msg)]);

const result = await adapter.getMessages("user-1", "conv-1");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("msg-1");
expect(result[0].createdAt).toBeInstanceOf(Date);
});

it("applies limit option", async () => {
const messages = Array.from({ length: 5 }, (_, i) =>
JSON.stringify({
id: `msg-${i}`,
role: "user",
parts: [],
createdAt: new Date(2026, 0, 1, 0, i).toISOString(),
}),
);
mockRedis.zrange.mockResolvedValue(messages);

const result = await adapter.getMessages("user-1", "conv-1", { limit: 2 });
expect(result).toHaveLength(2);
});
});

describe("clearMessages", () => {
it("clears messages for a specific conversation", async () => {
await adapter.clearMessages("user-1", "conv-1");
expect(mockRedis.del).toHaveBeenCalledWith("test:msgs:conv-1");
});
});

// ── Working memory tests ─────────────────────────────────────────────

describe("workingMemory", () => {
it("sets and gets conversation-scoped working memory", async () => {
mockRedis.get.mockResolvedValue("memory content");

const result = await adapter.getWorkingMemory({
conversationId: "conv-1",
scope: "conversation",
});

expect(result).toBe("memory content");
expect(mockRedis.get).toHaveBeenCalledWith("test:wm:conv:conv-1");
});

it("sets user-scoped working memory", async () => {
await adapter.setWorkingMemory({
userId: "user-1",
content: "user memory",
scope: "user",
});

expect(mockRedis.set).toHaveBeenCalledWith("test:wm:user:user-1", "user memory");
});

it("deletes working memory", async () => {
await adapter.deleteWorkingMemory({
conversationId: "conv-1",
scope: "conversation",
});

expect(mockRedis.del).toHaveBeenCalledWith("test:wm:conv:conv-1");
});
});

// ── Workflow state tests ─────────────────────────────────────────────

describe("workflowState", () => {
it("stores and retrieves workflow state", async () => {
const state = {
id: "exec-1",
workflowId: "wf-1",
workflowName: "Test Workflow",
status: "running" as const,
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
};

mockRedis.get.mockResolvedValue(
JSON.stringify({
...state,
createdAt: state.createdAt.toISOString(),
updatedAt: state.updatedAt.toISOString(),
}),
);

const result = await adapter.getWorkflowState("exec-1");
expect(result?.id).toBe("exec-1");
expect(result?.workflowId).toBe("wf-1");
expect(result?.createdAt).toBeInstanceOf(Date);
});

it("returns null for nonexistent workflow state", async () => {
mockRedis.get.mockResolvedValue(null);
const result = await adapter.getWorkflowState("nonexistent");
expect(result).toBeNull();
});
});

// ── Disconnect ───────────────────────────────────────────────────────

describe("disconnect", () => {
it("calls quit on the Redis client", async () => {
await adapter.disconnect();
expect(mockRedis.quit).toHaveBeenCalled();
});
});
});
Loading
Loading