fix: clarify cache epoch semantics and add regression tests

- Update plugin.ts comments to describe 'session cache epoch' instead
  of misleading 'session lifetime' wording
- Add regression test: same-session explicit memory does not mutate
  frozen system[1]; pending memory goes to ephemeral system[2+]
- Add regression test: session.compacted intentionally refreshes
  system[1] as a new cache epoch boundary (promotes pending memories,
  clears frozen cache, next transform re-renders workspace memory)
- Both tests use one plugin instance with mutable mock client to
  preserve in-memory frozen cache across turns
This commit is contained in:
Ralph Chang
2026-04-27 09:55:03 +08:00
parent 3560868f52
commit 2437a9dc71
2 changed files with 160 additions and 3 deletions
+13 -3
View File
@@ -2,10 +2,18 @@
* Memory V2 Plugin for OpenCode
*
* Architecture:
* - Layer 1: Stable Workspace Memory (frozen per session, refreshed at compaction)
* - Layer 2: Hot Session State (active files, open errors, recent decisions)
* - Layer 1: Stable Workspace Memory (frozen per session cache epoch, refreshed at compaction)
* - Layer 2: Hot Session State (active files, open errors, recent decisions, pending memories)
* - Layer 3: Native OpenCode State (todos owned by OpenCode, read during compaction)
*
* Cache Epoch Model:
* - Each session creates a frozen workspace memory snapshot on first transform.
* - Normal turns reuse the exact rendered string (system[1] remains stable).
* - Compaction starts a new cache epoch: pending memories are promoted, the cache is cleared,
* and the next transform re-renders workspace memory.
* - Explicit memory ("remember X") goes to SessionState.pendingMemories + durable journal,
* visible in ephemeral system[2+] for the current epoch, promoted to system[1] after compaction.
*
* This plugin:
* - Caches frozen workspace memory per sessionID
* - Processes explicit memory from latest user text once per message id
@@ -286,7 +294,9 @@ export const MemoryV2Plugin: Plugin = async (input) => {
const now = Date.now();
const cached = frozenWorkspaceMemoryCache.get(sessionID);
// Cache is valid for the session lifetime
// Cache is valid for the current session cache epoch.
// It is intentionally invalidated after compaction so promoted memories
// become visible in the next compacted context (new epoch starts).
if (cached) {
return { store: cached.store, renderedPrompt: cached.renderedPrompt };
}
+147
View File
@@ -9,6 +9,7 @@ import { parseWorkspaceMemoryCandidates } from "../src/extractors.ts";
import type { OpenError } from "../src/types.ts";
import { workspaceMemoryPath, workspacePendingJournalPath } from "../src/paths.ts";
import { loadPendingJournal, savePendingJournal } from "../src/pending-journal.ts";
import { updateWorkspaceMemory } from "../src/workspace-memory.ts";
// Mock client for root session (not a sub-agent)
function mockRootClient() {
@@ -553,6 +554,152 @@ test("session.compacted promotes pending memories to workspace memory and clears
}
});
test("same-session explicit memory does not mutate frozen system[1]", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
// 1. Seed workspace memory so system[1] exists before explicit memory is added.
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_existing_stable",
type: "project",
text: "Existing stable workspace memory.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: now,
updatedAt: now,
});
return store;
});
// 2. Use one plugin instance with a mutable mock client so the in-memory
// frozen cache is preserved across turns while the latest user message changes.
let latestMessages: Array<Record<string, unknown>> = [];
const client = {
session: {
get: async () => ({ data: { parentID: null } }),
messages: async () => ({ data: latestMessages }),
todo: async () => ({ data: [] }),
},
};
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
const output1 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "frozen-cache-session", model: {} },
output1,
);
const firstSystem1 = output1.system.find((part: string) => part.startsWith("Workspace memory"));
assert.match(firstSystem1 ?? "", /Existing stable workspace memory/,
"first transform should create a frozen workspace memory system[1]");
// 3. User says "remember X" in the same session.
latestMessages = [{
info: { role: "user", id: "msg-explicit-1" },
parts: [{ type: "text", text: "remember this: Same-session memory stays ephemeral." }],
}];
const output2 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "frozen-cache-session", model: {} },
output2,
);
// 4. Assert: workspace system[1] unchanged (frozen snapshot).
const secondSystem1 = output2.system.find((part: string) => part.startsWith("Workspace memory"));
assert.equal(secondSystem1, firstSystem1,
"frozen system[1] must not change after explicit memory in same session");
// 5. Assert: hot state (system[2+]) contains the pending memory.
const hotState = output2.system.find((part: string) => part.includes("Hot session state"));
assert.ok(hotState, "hot session state should be rendered");
assert.match(hotState, /pending_memories:/,
"hot state should contain pending_memories section");
assert.match(hotState, /Same-session memory stays ephemeral/,
"hot state should contain the explicit memory text");
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("compaction intentionally refreshes frozen system[1] with promoted memories", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
// 1. First transform creates frozen system[1]
const client = mockRootClient();
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
const output1 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "compaction-refresh-session", model: {} },
output1,
);
const firstSystem1 = output1.system[1]; // workspace memory snapshot
// 2. Add pending memory to session state
await saveSessionState(tmpDir, {
version: 1,
sessionID: "compaction-refresh-session",
turn: 1,
updatedAt: new Date().toISOString(),
activeFiles: [],
openErrors: [],
recentDecisions: [],
pendingMemories: [{
id: "mem_compaction_test",
type: "decision",
text: "Compaction refreshes frozen snapshot.",
source: "explicit",
confidence: 1,
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}],
});
// 3. Fire session.compacted event
await (plugin as Record<string, Function>)["event"]({
event: {
type: "session.compacted",
properties: { sessionID: "compaction-refresh-session" },
},
});
// 4. Next transform same session - system[1] should be refreshed
const output2 = { system: ["base header"] };
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "compaction-refresh-session", model: {} },
output2,
);
const secondSystem1 = output2.system[1];
// 5. Assert: system[1] changed (compaction started new cache epoch)
assert.notEqual(secondSystem1, firstSystem1,
"frozen system[1] should change after compaction (new cache epoch)");
// 6. Assert: promoted memory is now in system[1]
assert.match(secondSystem1 ?? "", /Compaction refreshes frozen snapshot/,
"promoted memory should appear in refreshed system[1]");
// 7. Assert: pending memory cleared from hot state
const hotState = output2.system.find((part: string) => part.includes("Hot session state"));
if (hotState) {
assert.equal(hotState.includes("pending_memories:"), false,
"pending_memories should be cleared after promotion");
}
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("promotion failure does not clear pending memories in session or journal", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));