feat(tools): add client-side memory tool with git-backed sync (#1363)
This commit is contained in:
209
src/tests/tools/memory-tool.test.ts
Normal file
209
src/tests/tools/memory-tool.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { execFile as execFileCb } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFile = promisify(execFileCb);
|
||||
|
||||
const TEST_AGENT_ID = "agent-test-memory-tool";
|
||||
const TEST_AGENT_NAME = "Bob";
|
||||
|
||||
mock.module("../../agent/context", () => ({
|
||||
getCurrentAgentId: () => TEST_AGENT_ID,
|
||||
}));
|
||||
|
||||
mock.module("../../agent/client", () => ({
|
||||
getClient: mock(() =>
|
||||
Promise.resolve({
|
||||
agents: {
|
||||
retrieve: mock(() => Promise.resolve({ name: TEST_AGENT_NAME })),
|
||||
},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
const { memory } = await import("../../tools/impl/Memory");
|
||||
|
||||
async function runGit(cwd: string, args: string[]): Promise<string> {
|
||||
const { stdout } = await execFile("git", args, { cwd });
|
||||
return String(stdout ?? "").trim();
|
||||
}
|
||||
|
||||
describe("memory tool", () => {
|
||||
let tempRoot: string;
|
||||
let memoryDir: string;
|
||||
let remoteDir: string;
|
||||
|
||||
const originalMemoryDir = process.env.MEMORY_DIR;
|
||||
const originalAgentId = process.env.AGENT_ID;
|
||||
const originalAgentName = process.env.AGENT_NAME;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempRoot = mkdtempSync(join(tmpdir(), "letta-memory-tool-"));
|
||||
memoryDir = join(tempRoot, "memory");
|
||||
remoteDir = join(tempRoot, "remote.git");
|
||||
|
||||
// Bare remote
|
||||
await execFile("git", ["init", "--bare", remoteDir]);
|
||||
|
||||
// Local memory repo
|
||||
await execFile("git", ["init", "-b", "main", memoryDir]);
|
||||
await runGit(memoryDir, ["config", "user.name", "setup"]);
|
||||
await runGit(memoryDir, ["config", "user.email", "setup@example.com"]);
|
||||
await runGit(memoryDir, ["remote", "add", "origin", remoteDir]);
|
||||
|
||||
writeFileSync(join(memoryDir, ".gitkeep"), "", "utf8");
|
||||
await runGit(memoryDir, ["add", ".gitkeep"]);
|
||||
await runGit(memoryDir, ["commit", "-m", "initial"]);
|
||||
await runGit(memoryDir, ["push", "-u", "origin", "main"]);
|
||||
|
||||
process.env.MEMORY_DIR = memoryDir;
|
||||
process.env.AGENT_ID = TEST_AGENT_ID;
|
||||
process.env.AGENT_NAME = TEST_AGENT_NAME;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalMemoryDir === undefined) delete process.env.MEMORY_DIR;
|
||||
else process.env.MEMORY_DIR = originalMemoryDir;
|
||||
|
||||
if (originalAgentId === undefined) delete process.env.AGENT_ID;
|
||||
else process.env.AGENT_ID = originalAgentId;
|
||||
|
||||
if (originalAgentName === undefined) delete process.env.AGENT_NAME;
|
||||
else process.env.AGENT_NAME = originalAgentName;
|
||||
|
||||
if (tempRoot) {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("requires reason", async () => {
|
||||
await expect(
|
||||
memory({
|
||||
command: "create",
|
||||
path: "system/test.md",
|
||||
description: "test desc",
|
||||
} as Parameters<typeof memory>[0]),
|
||||
).rejects.toThrow(/missing required parameter/i);
|
||||
});
|
||||
|
||||
test("uses reason as commit message and agent identity as commit author", async () => {
|
||||
const reason = "Create coding preferences block";
|
||||
|
||||
await memory({
|
||||
command: "create",
|
||||
reason,
|
||||
path: "system/human/prefs/coding.md",
|
||||
description: "The user's coding preferences.",
|
||||
file_text: "The user likes explicit types.",
|
||||
});
|
||||
|
||||
const logOutput = await runGit(memoryDir, [
|
||||
"log",
|
||||
"-1",
|
||||
"--pretty=format:%s%n%an%n%ae",
|
||||
]);
|
||||
const [subject, authorName, authorEmail] = logOutput.split("\n");
|
||||
|
||||
expect(subject).toBe(reason);
|
||||
expect(authorName).toBe(TEST_AGENT_NAME);
|
||||
expect(authorEmail).toBe(`${TEST_AGENT_ID}@letta.com`);
|
||||
|
||||
const remoteSubject = await execFile(
|
||||
"git",
|
||||
["--git-dir", remoteDir, "log", "-1", "--pretty=format:%s", "main"],
|
||||
{},
|
||||
).then((r) => String(r.stdout ?? "").trim());
|
||||
expect(remoteSubject).toBe(reason);
|
||||
});
|
||||
|
||||
test("returns error when push fails but keeps local commit", async () => {
|
||||
await memory({
|
||||
command: "create",
|
||||
reason: "Seed notes",
|
||||
path: "reference/history/notes.md",
|
||||
description: "Notes block",
|
||||
file_text: "old value",
|
||||
});
|
||||
|
||||
await runGit(memoryDir, [
|
||||
"remote",
|
||||
"set-url",
|
||||
"origin",
|
||||
join(tempRoot, "missing-remote.git"),
|
||||
]);
|
||||
|
||||
const reason = "Update notes after remote failure";
|
||||
|
||||
await expect(
|
||||
memory({
|
||||
command: "str_replace",
|
||||
reason,
|
||||
path: "reference/history/notes.md",
|
||||
old_string: "old value",
|
||||
new_string: "new value",
|
||||
}),
|
||||
).rejects.toThrow(/committed .* but push failed/i);
|
||||
|
||||
const subject = await runGit(memoryDir, [
|
||||
"log",
|
||||
"-1",
|
||||
"--pretty=format:%s",
|
||||
]);
|
||||
expect(subject).toBe(reason);
|
||||
});
|
||||
|
||||
test("falls back to context agent id when AGENT_ID env is missing", async () => {
|
||||
delete process.env.AGENT_ID;
|
||||
delete process.env.LETTA_AGENT_ID;
|
||||
|
||||
const reason = "Create identity via context fallback";
|
||||
await memory({
|
||||
command: "create",
|
||||
reason,
|
||||
path: "system/human/identity.md",
|
||||
description: "Identity block",
|
||||
file_text: "Name: Bob",
|
||||
});
|
||||
|
||||
const authorEmail = await runGit(memoryDir, [
|
||||
"log",
|
||||
"-1",
|
||||
"--pretty=format:%ae",
|
||||
]);
|
||||
expect(authorEmail).toBe(`${TEST_AGENT_ID}@letta.com`);
|
||||
});
|
||||
|
||||
test("accepts relative file paths like system/contacts.md", async () => {
|
||||
const reason = "Create contacts via relative path";
|
||||
|
||||
await memory({
|
||||
command: "create",
|
||||
reason,
|
||||
path: "system/contacts.md",
|
||||
description: "Contacts memory",
|
||||
file_text: "Sarah: +1-555-0100",
|
||||
});
|
||||
|
||||
const content = await runGit(memoryDir, [
|
||||
"show",
|
||||
"HEAD:system/contacts.md",
|
||||
]);
|
||||
expect(content).toContain("description: Contacts memory");
|
||||
expect(content).toContain("Sarah: +1-555-0100");
|
||||
});
|
||||
|
||||
test("rejects /memories-style paths", async () => {
|
||||
await expect(
|
||||
memory({
|
||||
command: "create",
|
||||
reason: "should fail",
|
||||
path: "/memories/contacts",
|
||||
description: "Contacts memory",
|
||||
}),
|
||||
).rejects.toThrow(/relative path like system\/contacts\.md/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user