/**
* Tests for the `memory-v2-static` runtime injector.
*
* Covers:
* - Returns null when the v2 static memory files are absent/empty.
* - Returns null when `mode === "minimal"`.
* - Wraps content in `...` and uses
* `after-memory-prefix` placement.
* - Escapes any `` substring inside the authored content so the
* wrapper cannot be broken out of.
* - Skips (re)injection when the `` block is already present in the
* turn's working messages (presence detection — the block persists in
* history between compactions).
* - Still injects when only a dynamic `` activation block is present
* (that wrapper must not be mistaken for the static block) — both on a
* normal turn and after compaction strips the prior `` block.
*
* The injector sources its content itself via `readMemoryV2StaticContent()`
* behind the personal-memory trust gate, so each test seeds the workspace
* memory files rather than passing the content in as an option. The v2 gates
* (`memory.enabled`, `memory.v2.enabled`) are on by schema default, so the
* real config loader needs no seeding.
*/
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { memoryInjectors } from "../plugins/defaults/memory/injectors.js";
import type { Injector, TurnContext } from "../plugins/types.js";
import type { Message } from "../providers/types.js";
import { getWorkspacePromptPath } from "../util/platform.js";
function findInjector(name: string): Injector {
const injector = memoryInjectors.find((i) => i.name === name);
if (!injector) {
throw new Error(`injector '${name}' not registered`);
}
return injector;
}
function makeContext(overrides: Partial = {}): TurnContext {
return {
requestId: "req-test",
conversationId: "conv-test",
turnIndex: 0,
trust: { sourceChannel: "vellum", trustClass: "guardian" },
...overrides,
};
}
/** Seed a single v2 static memory section under `## Essentials`. */
function seedEssentials(body: string): void {
const path = getWorkspacePromptPath("memory/essentials.md");
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, body, "utf-8");
}
function seedThreads(body: string): void {
const path = getWorkspacePromptPath("memory/threads.md");
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, body, "utf-8");
}
function seedBuffer(body: string): void {
const path = getWorkspacePromptPath("memory/buffer.md");
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, body, "utf-8");
}
function clearV2StaticFiles(): void {
for (const file of [
"memory/essentials.md",
"memory/threads.md",
"memory/recent.md",
"memory/buffer.md",
]) {
rmSync(getWorkspacePromptPath(file), { force: true });
}
}
const memoryV2StaticInjector = findInjector("memory-v2-static");
describe("memory-v2-static injector", () => {
beforeEach(() => clearV2StaticFiles());
afterEach(() => clearV2StaticFiles());
test("returns null when the v2 static memory files are absent", async () => {
const ctx = makeContext();
expect(await memoryV2StaticInjector.produce(ctx)).toBeNull();
});
test("returns null in minimal mode even with content", async () => {
seedEssentials("Alice prefers VS Code.");
const ctx = makeContext({ mode: "minimal" });
expect(await memoryV2StaticInjector.produce(ctx)).toBeNull();
});
test("wraps content in ... with after-memory-prefix placement", async () => {
seedEssentials("Alice prefers VS Code.");
seedThreads("Open: ship PR.");
const ctx = makeContext();
const block = await memoryV2StaticInjector.produce(ctx);
expect(block).not.toBeNull();
expect(block!.id).toBe("memory-v2-static");
expect(block!.placement).toBe("after-memory-prefix");
expect(block!.text).toBe(
"\n## Essentials\n\nAlice prefers VS Code.\n\n## Threads\n\nOpen: ship PR.\n",
);
});
test("suppresses the Buffer section on memoryV2Consolidation turns", async () => {
seedEssentials("Alice prefers VS Code.");
seedBuffer("- [Apr 27, 9:00 AM] backlog entry");
const consolidationBlock = await memoryV2StaticInjector.produce(
makeContext({ callSite: "memoryV2Consolidation" }),
);
expect(consolidationBlock).not.toBeNull();
expect(consolidationBlock!.text).toContain("## Essentials");
expect(consolidationBlock!.text).not.toContain("## Buffer");
// Other call sites (and untagged turns) keep the buffer section.
const mainBlock = await memoryV2StaticInjector.produce(makeContext());
expect(mainBlock!.text).toContain("## Buffer");
});
test("escapes inner closing tags so the wrapper cannot be broken out of", async () => {
seedEssentials("Text with embedded.");
const ctx = makeContext();
const block = await memoryV2StaticInjector.produce(ctx);
expect(block).not.toBeNull();
expect(block!.text).toBe(
"\n## Essentials\n\nText with </info> embedded.\n",
);
});
test("skips (re)injection when the block is already present", async () => {
seedEssentials("Alice prefers VS Code.");
const ctx = makeContext();
const runMessages: Message[] = [
{
role: "user",
content: [
{ type: "text", text: "\nstale memory\n" },
{ type: "text", text: "What next?" },
],
},
];
expect(await memoryV2StaticInjector.produce(ctx, runMessages)).toBeNull();
});
test("injects the block even when a dynamic activation block is present", async () => {
// Regression (#33612): the v2 *dynamic* activation block uses the
// `…` wrapper and `prepareMemory` prepends it to the tail
// user message every turn, before this injector runs. The static injector
// must NOT treat that as its own `` block — doing so suppressed the
// static block on essentially every turn.
seedEssentials("Alice prefers VS Code.");
const ctx = makeContext();
const runMessages: Message[] = [
{
role: "user",
content: [
{
type: "text",
text: "\n# memory/concepts/foo.md\nactivated page summary\n",
},
{ type: "text", text: "What next?" },
],
},
];
const block = await memoryV2StaticInjector.produce(ctx, runMessages);
expect(block).not.toBeNull();
expect(block!.id).toBe("memory-v2-static");
expect(block!.text).toBe(
"\n## Essentials\n\nAlice prefers VS Code.\n",
);
});
test("reinjects the block after compaction strips it, alongside a fresh block", async () => {
// Post-compaction state: `stripInjectionsForCompaction` removed the prior
// `` block, and the next turn's `prepareMemory` re-added a fresh
// dynamic `` block ahead of the chain. With no `` present, the
// injector must reinject it rather than skip on the `` block.
seedEssentials("Alice prefers VS Code.");
const ctx = makeContext();
const postCompactMessages: Message[] = [
{
role: "user",
content: [
{ type: "text", text: "\nactivated page summary\n" },
{ type: "text", text: "Continue please." },
],
},
];
const block = await memoryV2StaticInjector.produce(
ctx,
postCompactMessages,
);
expect(block).not.toBeNull();
expect(block!.id).toBe("memory-v2-static");
expect(block!.text).toContain("## Essentials");
});
test("skips (re)injection when the block is carried in a flattened history block", async () => {
// Regression: when a historical user message's separate injection content
// blocks get collapsed into one text block (observed after a daemon restart
// / plugin hot-reload rebuilt the rendered history mid-conversation — the
// blocks are joined with "\n"), the `` wrapper is no longer its own
// block but a newline-delimited section inside a larger block. Presence
// detection must still recognize it; otherwise the injector re-injects a
// duplicate `` onto the new tail (the double-injection bug).
seedEssentials("Alice prefers VS Code.");
const ctx = makeContext();
const flattenedFirstUser = [
"\nRoot: /ws\nFiles: a.md\n",
"\ncurrent_time: now\n",
"\nactivated page summary\n",
"\n## Essentials\n\nAlice prefers VS Code.\n",
"what should we do next?",
].join("\n");
const runMessages: Message[] = [
{ role: "user", content: [{ type: "text", text: flattenedFirstUser }] },
{ role: "assistant", content: [{ type: "text", text: "On it." }] },
{ role: "user", content: [{ type: "text", text: "great, continue" }] },
];
expect(await memoryV2StaticInjector.produce(ctx, runMessages)).toBeNull();
});
test("still injects when a user message opens an tag without closing it", async () => {
// False-positive guard: the wrapper matcher requires BOTH the opening and
// closing tags, so user prose that merely opens an injection-like tag must
// not be mistaken for a carried block and suppress injection.
seedEssentials("Alice prefers VS Code.");
const ctx = makeContext();
const runMessages: Message[] = [
{
role: "user",
content: [
{ type: "text", text: "\n is this how the tag works?" },
],
},
];
const block = await memoryV2StaticInjector.produce(ctx, runMessages);
expect(block).not.toBeNull();
expect(block!.id).toBe("memory-v2-static");
});
});