From 2437a9dc7159eb7940f1aa90e2fd85aeb07705e0 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Mon, 27 Apr 2026 09:55:03 +0800 Subject: [PATCH] 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 --- src/plugin.ts | 16 ++++- tests/plugin.test.ts | 147 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 8ea2afb..035ca1e 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -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 }; } diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index a40ea9d..86f0de5 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -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> = []; + 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)["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)["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)["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)["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)["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-"));