fix: promotion accounting, sessionID extraction, and strengthened regression tests

Architecture review fixes:

- Promotion accounting: only clear pending memories that survived
  workspace memory normalization/cap limits. Use retainedKeys from
  the returned normalized store instead of attemptedKeys.

- Shared sessionID extraction: add sessionIDFromEventProperties()
  helper and use it in both session.compacted and session.deleted,
  fixing the previous gap where session.deleted only read info.id.

- Strengthen compaction refresh test: seed workspace memory before
  first transform so firstSystem1 is non-empty, then assert
  refreshed system[1] preserves existing entries AND contains
  promoted memories.
This commit is contained in:
Ralph Chang
2026-04-27 10:02:18 +08:00
parent 2437a9dc71
commit 4309cb855f
2 changed files with 37 additions and 12 deletions
+14 -8
View File
@@ -231,8 +231,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
];
if (pending.length === 0) return;
const promotedKeys = new Set<string>();
await updateWorkspaceMemory(directory, workspaceMemory => {
const updatedWorkspaceMemory = await updateWorkspaceMemory(directory, workspaceMemory => {
const existingKeys = new Set(workspaceMemory.entries.map(memory => memoryKey(memory)));
for (const memory of pending) {
@@ -241,21 +240,24 @@ export const MemoryV2Plugin: Plugin = async (input) => {
workspaceMemory.entries.push(memory);
existingKeys.add(key);
}
promotedKeys.add(key);
}
return workspaceMemory;
});
// Only clear pending memories that survived workspace normalization/limits.
// updateWorkspaceMemory() may dedupe, supersede, redact, or cap entries.
const retainedKeys = new Set(updatedWorkspaceMemory.entries.map(memory => memoryKey(memory)));
if (sessionID) {
await updateSessionState(directory, sessionID, state => {
state.pendingMemories = state.pendingMemories.filter(memory => !promotedKeys.has(memoryKey(memory)));
state.pendingMemories = state.pendingMemories.filter(memory => !retainedKeys.has(memoryKey(memory)));
return state;
});
clearFrozenWorkspaceMemoryCache(sessionID);
}
await clearPendingMemories(directory, promotedKeys);
await clearPendingMemories(directory, retainedKeys);
}
function bashExitCode(hookOutput: unknown): number | undefined {
@@ -314,6 +316,11 @@ export const MemoryV2Plugin: Plugin = async (input) => {
frozenWorkspaceMemoryCache.delete(sessionID);
}
function sessionIDFromEventProperties(properties: unknown): string | undefined {
const props = properties as { sessionID?: string; info?: { id?: string } } | undefined;
return props?.sessionID ?? props?.info?.id;
}
return {
// Inject workspace memory and hot session state into system prompt
"experimental.chat.system.transform": async (hookInput, output) => {
@@ -469,8 +476,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
// Handle session events
event: async ({ event }) => {
if (event.type === "session.compacted") {
const sessionID = (event.properties as { sessionID?: string; info?: { id?: string } })?.sessionID
?? (event.properties as { info?: { id?: string } })?.info?.id;
const sessionID = sessionIDFromEventProperties(event.properties);
if (!sessionID) return;
// Sub-agents don't need post-compaction processing
@@ -492,7 +498,7 @@ export const MemoryV2Plugin: Plugin = async (input) => {
}
if (event.type === "session.deleted") {
const sessionID = (event.properties as { info?: { id?: string } })?.info?.id;
const sessionID = sessionIDFromEventProperties(event.properties);
if (sessionID) {
// Promote pending memories before deleting per-session state.
// If promotion fails, leave session state and journal intact.
+23 -4
View File
@@ -630,7 +630,22 @@ test("compaction intentionally refreshes frozen system[1] with promoted memories
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
// 1. First transform creates frozen system[1]
// 1. Seed workspace memory, then first transform creates non-empty frozen system[1].
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
store.entries.push({
id: "mem_old_stable",
type: "project",
text: "Old stable memory.",
source: "compaction",
confidence: 0.9,
status: "active",
createdAt: now,
updatedAt: now,
});
return store;
});
const client = mockRootClient();
const plugin = await MemoryV2Plugin({ directory: tmpDir, client });
@@ -640,7 +655,9 @@ test("compaction intentionally refreshes frozen system[1] with promoted memories
output1,
);
const firstSystem1 = output1.system[1]; // workspace memory snapshot
const firstSystem1 = output1.system.find((part: string) => part.startsWith("Workspace memory"));
assert.match(firstSystem1 ?? "", /Old stable memory/,
"first transform should create a non-empty frozen system[1]");
// 2. Add pending memory to session state
await saveSessionState(tmpDir, {
@@ -678,13 +695,15 @@ test("compaction intentionally refreshes frozen system[1] with promoted memories
output2,
);
const secondSystem1 = output2.system[1];
const secondSystem1 = output2.system.find((part: string) => part.startsWith("Workspace memory"));
// 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]
// 6. Assert: old stable memory is preserved and promoted memory is now in system[1]
assert.match(secondSystem1 ?? "", /Old stable memory/,
"refreshed system[1] should preserve existing workspace memory");
assert.match(secondSystem1 ?? "", /Compaction refreshes frozen snapshot/,
"promoted memory should appear in refreshed system[1]");