106 lines
4.8 KiB
TypeScript
106 lines
4.8 KiB
TypeScript
/**
|
|
* Tests for #3890: prompt-cache misses when memory injection is enabled.
|
|
*
|
|
* When the client uses prompt caching (cache_control breakpoints), the previous behavior
|
|
* prepended the (per-query, varying) memory message at index 0 — shifting the entire
|
|
* cacheable prefix and forcing a cache miss on every turn. With `cacheSafe`, memory is
|
|
* inserted just before the last user message so the cacheable prefix stays byte-stable.
|
|
*/
|
|
|
|
import { describe, it } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { injectMemory } from "../../src/lib/memory/injection.ts";
|
|
import type { ChatRequest } from "../../src/lib/memory/injection.ts";
|
|
import type { Memory } from "../../src/lib/memory/types.ts";
|
|
|
|
function mem(content: string): Memory {
|
|
return {
|
|
id: `mem-${content}`,
|
|
content,
|
|
type: "factual" as any,
|
|
apiKeyId: "k",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
importance: 0.5,
|
|
};
|
|
}
|
|
|
|
function multiTurn(): ChatRequest {
|
|
return {
|
|
model: "anthropic/claude-sonnet-4-6",
|
|
messages: [
|
|
{ role: "system", content: "SYSTEM PROMPT", cache_control: { type: "ephemeral" } } as any,
|
|
{ role: "user", content: "turn 1 question" },
|
|
{ role: "assistant", content: "turn 1 answer" },
|
|
{ role: "user", content: "turn 2 question" },
|
|
],
|
|
};
|
|
}
|
|
|
|
describe("injectMemory cache-safe positioning (#3890)", () => {
|
|
it("default (cacheSafe off) prepends memory at index 0 — unchanged legacy behavior", () => {
|
|
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic");
|
|
assert.equal(out.messages[0].role, "system");
|
|
assert.ok(out.messages[0].content.includes("Memory context"));
|
|
assert.equal(out.messages[1].content, "SYSTEM PROMPT");
|
|
});
|
|
|
|
it("cacheSafe inserts memory just before the last user message, preserving the prefix", () => {
|
|
const req = multiTurn();
|
|
const prefixBefore = JSON.stringify(req.messages.slice(0, 3)); // sys, u1, a1
|
|
const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true });
|
|
|
|
// The cacheable prefix (system + prior turns up to the last assistant) is byte-identical.
|
|
assert.equal(JSON.stringify(out.messages.slice(0, 3)), prefixBefore);
|
|
// Memory is injected right before the last user message...
|
|
assert.equal(out.messages[3].role, "system");
|
|
assert.ok(out.messages[3].content.includes("Memory context"));
|
|
// ...and the last user message is preserved at the tail.
|
|
assert.equal(out.messages[4].content, "turn 2 question");
|
|
assert.equal(out.messages.length, 5);
|
|
// Memory must NOT be at index 0 (that is what broke caching).
|
|
assert.notEqual(out.messages[0].content, out.messages[3].content);
|
|
assert.equal(out.messages[0].content, "SYSTEM PROMPT");
|
|
});
|
|
|
|
it("cacheSafe keeps the cacheable prefix identical across two turns despite different memories", () => {
|
|
// Turn 1: [sys, u1]; Turn 2: [sys, u1, a1, u2]. Different memories retrieved per query.
|
|
// Same conversation, observed on two consecutive turns.
|
|
const turn1: ChatRequest = {
|
|
model: "anthropic/claude-sonnet-4-6",
|
|
messages: [
|
|
{ role: "system", content: "SYSTEM PROMPT", cache_control: { type: "ephemeral" } } as any,
|
|
{ role: "user", content: "turn 1 question" },
|
|
],
|
|
};
|
|
const turn2 = multiTurn();
|
|
|
|
const out1 = injectMemory(turn1, [mem("A")], "anthropic", { cacheSafe: true });
|
|
const out2 = injectMemory(turn2, [mem("B")], "anthropic", { cacheSafe: true });
|
|
|
|
// The cache-breakpoint-bearing system message stays at the head, byte-identical, in
|
|
// both turns (and is NOT displaced by the per-query memory) — so the prompt cache
|
|
// created on turn 1 still matches on turn 2. (Memory is inserted before the last user
|
|
// turn: [SYS, MEM_A, u1] and [SYS, u1, a1, MEM_B, u2] respectively.)
|
|
assert.deepEqual(out1.messages[0], out2.messages[0]);
|
|
assert.equal(out1.messages[0].content, "SYSTEM PROMPT");
|
|
assert.equal((out1.messages[0] as any).cache_control?.type, "ephemeral");
|
|
// In turn 2 the earlier turns up to the last assistant are preserved before memory.
|
|
assert.equal(out2.messages[1].content, "turn 1 question");
|
|
assert.equal(out2.messages[2].content, "turn 1 answer");
|
|
assert.ok(out2.messages[3].content.includes("Memory context"));
|
|
assert.equal(out2.messages[4].content, "turn 2 question");
|
|
});
|
|
|
|
it("cacheSafe falls back to leading injection when there is no user message", () => {
|
|
const req: ChatRequest = {
|
|
model: "anthropic/claude-sonnet-4-6",
|
|
messages: [{ role: "system", content: "SYS" }],
|
|
};
|
|
const out = injectMemory(req, [mem("x")], "anthropic", { cacheSafe: true });
|
|
assert.equal(out.messages[0].role, "system");
|
|
assert.ok(out.messages[0].content.includes("Memory context"));
|
|
assert.equal(out.messages[1].content, "SYS");
|
|
});
|
|
});
|