import test from "node:test"; import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { MemoryV2Plugin } from "../src/plugin.ts"; import * as sessionStateModule from "../src/session-state.ts"; import type { HotSessionStateRenderAccounting } from "../src/session-state.ts"; import { sessionStatePath, workspaceMemoryPath } from "../src/paths.ts"; import type { ActiveFile, CompactionMemoryRef, LongTermMemoryEntry, OpenError, SessionDecision, SessionState } from "../src/types.ts"; import { HOT_STATE_LIMITS, LONG_TERM_LIMITS } from "../src/types.ts"; type AccountHotSessionStateRender = (state: SessionState, workspaceRoot: string) => HotSessionStateRenderAccounting; const accountHotSessionStateRender = ( sessionStateModule as typeof sessionStateModule & { accountHotSessionStateRender: AccountHotSessionStateRender } ).accountHotSessionStateRender; const { createEmptySessionState, loadSessionState, renderHotSessionState, saveSessionState } = sessionStateModule; const root = "/repo"; function state(overrides: Partial = {}): SessionState { return { version: 1, sessionID: "session-state-test", turn: 0, updatedAt: "2026-05-05T00:00:00.000Z", activeFiles: [], openErrors: [], recentDecisions: [], pendingMemories: [], compactionMemoryRefs: [], ...overrides, }; } function compactionRef(index: number, overrides: Partial = {}): CompactionMemoryRef { return { ref: `M${index}`, memoryId: `memory-${index}`, type: "decision", source: "compaction", exactKey: `decision:durable fact ${index}`, identityKey: `decision:durable fact ${index}`, textPreview: `Durable fact ${index}`, capturedAt: 1_777_000_000_000 + index, ...overrides, }; } function mockRootClient(summary = "") { return { session: { get: async () => ({ data: { parentID: null } }), messages: async () => ({ data: summary ? [{ info: { role: "assistant", summary: true }, parts: [{ type: "text", text: summary }] }] : [], }), todo: async () => ({ data: [] }), }, }; } function activeFile(path: string, action: ActiveFile["action"], count: number, lastSeen: number): ActiveFile { return { path, action, count, lastSeen }; } function openError(id: string, summary: string, lastSeen: number): OpenError { return { id, category: "test", summary, fingerprint: `fingerprint-${id}`, status: "open", firstSeen: lastSeen - 1, lastSeen, seenCount: 1, }; } function decision(id: string, text: string, createdAt: number): SessionDecision { return { id, text, source: "assistant", createdAt }; } function memory(id: string, text: string, type: LongTermMemoryEntry["type"] = "decision"): LongTermMemoryEntry { return { id, type, text, source: "compaction", confidence: 0.75, status: "active", createdAt: "2026-05-05T00:00:00.000Z", updatedAt: "2026-05-05T00:00:00.000Z", }; } test("accountHotSessionStateRender returns empty prompt and no omissions for empty state", () => { const accounting = accountHotSessionStateRender(createEmptySessionState("empty-session"), root); assert.equal(accounting.prompt, ""); assert.deepEqual(accounting.omitted, []); assert.equal(accounting.maxRenderedChars, HOT_STATE_LIMITS.maxRenderedChars); }); test("accountHotSessionStateRender renders hot-state sections in stable order", () => { const accounting = accountHotSessionStateRender(state({ activeFiles: [activeFile("/repo/src/a.ts", "read", 1, 1)], openErrors: [openError("err-1", "one failing test", 2)], recentDecisions: [decision("dec-1", "Keep renderer simple", 3)], pendingMemories: [memory("mem-1", "Promote useful fact")], }), root); assert.ok(accounting.prompt.startsWith("Hot session state (current session):")); assert.ok(accounting.prompt.indexOf("active_files:") < accounting.prompt.indexOf("open_errors:")); assert.ok(accounting.prompt.indexOf("open_errors:") < accounting.prompt.indexOf("recent_decisions:")); assert.ok(accounting.prompt.indexOf("recent_decisions:") < accounting.prompt.indexOf("pending_memories:")); }); test("accountHotSessionStateRender ranks active files by score then lastSeen descending", () => { const accounting = accountHotSessionStateRender(state({ activeFiles: [ activeFile("/repo/src/edit.ts", "edit", 1, 400), activeFile("/repo/src/write.ts", "write", 5, 200), activeFile("/repo/src/grep.ts", "grep", 10, 300), activeFile("/repo/src/read.ts", "read", 12, 100), ], }), root); const lines = accounting.prompt.split("\n"); const activeLines = lines.filter(line => line.startsWith("- src/")); assert.deepEqual(activeLines, [ "- src/grep.ts (grep, 10x)", "- src/write.ts (write, 5x)", "- src/read.ts (read, 12x)", "- src/edit.ts (edit, 1x)", ]); }); test("accountHotSessionStateRender reports section-cap omissions for every capped section", () => { const accounting = accountHotSessionStateRender(state({ activeFiles: Array.from({ length: 9 }, (_, index) => activeFile(`/repo/a${index}.ts`, "read", 1, 100 - index)), openErrors: Array.from({ length: 4 }, (_, index) => openError(`err-${index}`, `e${index}`, 100 - index)), recentDecisions: Array.from({ length: 9 }, (_, index) => decision(`dec-${index}`, `d${index}`, index)), pendingMemories: Array.from({ length: 7 }, (_, index) => memory(`mem-${index}`, `m${index}`)), }), root); const sectionCapOmissions = accounting.omitted.filter(item => item.reason === "section_cap"); assert.deepEqual( sectionCapOmissions.map(item => item.section).sort(), ["active_files", "open_errors", "pending_memories", "recent_decisions"].sort(), ); assert.equal(sectionCapOmissions.find(item => item.section === "pending_memories")?.memoryId, "mem-0"); }); test("accountHotSessionStateRender omits over-budget entries without cutting rendered lines", () => { const longPath = `/repo/${"x".repeat(650)}.ts`; const accounting = accountHotSessionStateRender(state({ activeFiles: [ activeFile("/repo/src/short.ts", "read", 1, 20), activeFile(longPath, "read", 1, 10), ], }), root); assert.equal(accounting.prompt, [ "Hot session state (current session):", "active_files:", "- src/short.ts (read, 1x)", ].join("\n")); assert.equal(accounting.omitted.length, 1); assert.equal(accounting.omitted[0]?.reason, "char_budget"); assert.equal(accounting.omitted[0]?.section, "active_files"); assert.ok(!accounting.prompt.includes("x".repeat(20))); }); test("accountHotSessionStateRender includes exact 700-char prompt but omits one additional character", () => { const fixedPrompt = [ "Hot session state (current session):", "pending_memories:", "- [decision] ", ].join("\n"); const exactText = "x".repeat(HOT_STATE_LIMITS.maxRenderedChars - fixedPrompt.length); const exactAccounting = accountHotSessionStateRender(state({ pendingMemories: [memory("mem-exact", exactText)], }), root); assert.equal(exactAccounting.prompt.length, HOT_STATE_LIMITS.maxRenderedChars); assert.equal(exactAccounting.omitted.length, 0); const overAccounting = accountHotSessionStateRender(state({ pendingMemories: [memory("mem-over", `${exactText}y`)], }), root); assert.equal(overAccounting.prompt, ""); assert.equal(overAccounting.omitted.length, 1); assert.equal(overAccounting.omitted[0]?.reason, "char_budget"); assert.equal(overAccounting.omitted[0]?.memoryId, "mem-over"); }); test("accountHotSessionStateRender suppresses header-only sections when no entries fit", () => { const accounting = accountHotSessionStateRender(state({ activeFiles: [activeFile(`/repo/${"z".repeat(720)}.ts`, "read", 1, 1)], }), root); assert.equal(accounting.prompt, ""); assert.equal(accounting.omitted.length, 1); assert.equal(accounting.omitted[0]?.reason, "char_budget"); assert.ok(!accounting.prompt.includes("active_files:")); }); test("renderHotSessionState delegates to accounted renderer prompt for empty and seeded states", () => { const empty = createEmptySessionState("compat-empty"); const seeded = state({ activeFiles: [activeFile("/repo/src/a.ts", "edit", 2, 1)], pendingMemories: [memory("mem-compat", "Compatibility prompt")], }); assert.equal(renderHotSessionState(empty, root), accountHotSessionStateRender(empty, root).prompt); assert.equal(renderHotSessionState(seeded, root), accountHotSessionStateRender(seeded, root).prompt); }); test("accountHotSessionStateRender counts newline separators in the 700-char budget", () => { const fixedPrompt = [ "Hot session state (current session):", "recent_decisions:", "- ", ].join("\n"); const exactText = "n".repeat(HOT_STATE_LIMITS.maxRenderedChars - fixedPrompt.length); const exactAccounting = accountHotSessionStateRender(state({ recentDecisions: [decision("dec-exact", exactText, 1)], }), root); assert.equal(exactAccounting.prompt.length, HOT_STATE_LIMITS.maxRenderedChars); assert.equal(exactAccounting.omitted.length, 0); const overAccounting = accountHotSessionStateRender(state({ recentDecisions: [decision("dec-over", `${exactText}!`, 1)], }), root); assert.equal(overAccounting.prompt, ""); assert.equal(overAccounting.omitted.length, 1); assert.equal(overAccounting.omitted[0]?.reason, "char_budget"); }); test("compaction memory refs round-trip through session state and are capped", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-")); try { const refs = Array.from({ length: LONG_TERM_LIMITS.maxEntries + 2 }, (_, index) => compactionRef( index + 1, index === 0 ? { compactionId: "compaction-snapshot-1" } : {}, )); await saveSessionState(tmpDir, state({ sessionID: "compaction-ref-roundtrip", compactionMemoryRefs: refs, })); const loaded = await loadSessionState(tmpDir, "compaction-ref-roundtrip"); assert.equal(loaded.compactionMemoryRefs.length, LONG_TERM_LIMITS.maxEntries); assert.deepEqual(loaded.compactionMemoryRefs, refs.slice(0, LONG_TERM_LIMITS.maxEntries)); } finally { await rm(tmpDir, { recursive: true, force: true }); } }); test("invalid stored compaction memory refs normalize to empty without crashing", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-")); try { const path = await sessionStatePath(tmpDir, "invalid-compaction-ref-session"); await mkdir(dirname(path), { recursive: true }); await writeFile(path, JSON.stringify({ version: 1, sessionID: "stale-id", turn: 0, updatedAt: "2026-05-05T00:00:00.000Z", activeFiles: [], openErrors: [], recentDecisions: [], pendingMemories: [], compactionMemoryRefs: [ compactionRef(1), { ...compactionRef(2), ref: "not-a-numbered-ref" }, ], }), "utf8"); const loaded = await loadSessionState(tmpDir, "invalid-compaction-ref-session"); assert.equal(loaded.sessionID, "invalid-compaction-ref-session"); assert.deepEqual(loaded.compactionMemoryRefs, []); } finally { await rm(tmpDir, { recursive: true, force: true }); } }); test("compaction memory refs never render in hot session state", () => { const rendered = renderHotSessionState(state({ activeFiles: [activeFile("/repo/src/a.ts", "read", 1, 1)], compactionMemoryRefs: [compactionRef(1, { memoryId: "secret-memory-id", exactKey: "decision:secret exact key", identityKey: "decision:secret identity key", textPreview: "Secret compaction ref preview must not render", })], }), root); assert.match(rendered, /active_files:/); assert.doesNotMatch(rendered, /Secret compaction ref preview/); assert.doesNotMatch(rendered, /secret-memory-id/); assert.doesNotMatch(rendered, /secret exact key/); assert.doesNotMatch(rendered, /secret identity key/); assert.doesNotMatch(rendered, /\bM1\b/); }); test("session.compacted clears compaction memory refs after processing", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-")); try { await saveSessionState(tmpDir, state({ sessionID: "clear-compaction-refs-session", compactionMemoryRefs: [compactionRef(1)], })); const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() }); await (plugin as Record)["event"]({ event: { type: "session.compacted", properties: { sessionID: "clear-compaction-refs-session" } }, }); const loaded = await loadSessionState(tmpDir, "clear-compaction-refs-session"); assert.deepEqual(loaded.compactionMemoryRefs, []); } finally { await rm(tmpDir, { recursive: true, force: true }); } }); test("session.compacted clears compaction memory refs even when promotion fails", async () => { const tmpDir = await mkdtemp(join(tmpdir(), "memory-session-state-")); try { await saveSessionState(tmpDir, state({ sessionID: "clear-compaction-refs-failure-session", pendingMemories: [memory("mem-pending-failure", "Keep pending memory when promotion fails")], compactionMemoryRefs: [compactionRef(1)], })); const workspacePath = await workspaceMemoryPath(tmpDir); await rm(workspacePath, { force: true }).catch(() => undefined); await mkdir(workspacePath, { recursive: true }); const plugin = await MemoryV2Plugin({ directory: tmpDir, client: mockRootClient() }); await (plugin as Record)["event"]({ event: { type: "session.compacted", properties: { sessionID: "clear-compaction-refs-failure-session" } }, }); const loaded = await loadSessionState(tmpDir, "clear-compaction-refs-failure-session"); assert.deepEqual(loaded.compactionMemoryRefs, []); assert.equal(loaded.pendingMemories.length, 1, "unrelated retryable pending memory should remain on promotion failure"); assert.equal(loaded.pendingMemories[0].id, "mem-pending-failure"); } finally { await rm(tmpDir, { recursive: true, force: true }); } });