{"version":3,"file":"session-memory.test.d.ts","sourceRoot":"","sources":["../../src/core/session-memory.test.ts"],"names":[],"mappings":"","sourcesContent":["import { describe, expect, it } from \"vitest\";\nimport type { MemoryHistorySnapshot } from \"./memory.js\";\nimport { memoryItemsToText, parseMemoryItems, SESSION_MEMORY_CUSTOM_TYPE, upsertMemoryItems } from \"./memory.js\";\nimport { computeMemorySnapshotDiff } from \"./memory-diff.js\";\nimport { SessionManager } from \"./session-manager.js\";\n\ndescribe(\"session memory\", () => {\n\tit(\"buildSessionContext injects latest session memory into model-facing messages\", () => {\n\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\t\tsession.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t{ key: \"constraints.test_command\", value: \"use npm run check\", timestamp: new Date().toISOString() },\n\t\t]);\n\t\tsession.appendMessage({\n\t\t\trole: \"user\",\n\t\t\tcontent: [{ type: \"text\", text: \"continue\" }],\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\n\t\tconst context = session.buildSessionContext();\n\t\texpect(context.memoryItems).toHaveLength(1);\n\t\texpect(context.messages[0]?.role).toBe(\"custom\");\n\t\tif (context.messages[0]?.role === \"custom\" && typeof context.messages[0].content === \"string\") {\n\t\t\texpect(context.messages[0].content).toContain(\"constraints.test_command\");\n\t\t}\n\t});\n\n\tit(\"upsertMemoryItems replaces values by key\", () => {\n\t\tconst timestamp = new Date().toISOString();\n\t\tconst first = upsertMemoryItems([], \"constraints.test_command\", \"npm run check\", timestamp);\n\t\tconst second = upsertMemoryItems(first, \"constraints.test_command\", \"pnpm lint\", timestamp);\n\t\texpect(second).toHaveLength(1);\n\t\texpect(second[0]?.value).toBe(\"pnpm lint\");\n\t});\n\n\tit(\"parseMemoryItems ignores invalid entries\", () => {\n\t\tconst items = parseMemoryItems([\n\t\t\t{ key: \"valid.key\", value: \"value\", timestamp: \"2024-01-01T00:00:00.000Z\" },\n\t\t\t{ key: 123, value: \"invalid\" },\n\t\t]);\n\t\texpect(items).toHaveLength(1);\n\t\texpect(items[0]?.key).toBe(\"valid.key\");\n\t});\n\n\tit(\"memoryItemsToText formats bullet list\", () => {\n\t\tconst text = memoryItemsToText([{ key: \"project.arch\", value: \"monorepo\", timestamp: new Date().toISOString() }]);\n\t\texpect(text).toContain(\"- project.arch: monorepo\");\n\t});\n\n\tdescribe(\"getMemoryHistory\", () => {\n\t\tit(\"returns empty array when no memory snapshots exist\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\t\t\tsession.appendMessage({\n\t\t\t\trole: \"user\",\n\t\t\t\tcontent: [{ type: \"text\", text: \"hello\" }],\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t});\n\n\t\t\tconst history = session.getMemoryHistory();\n\t\t\texpect(history).toEqual([]);\n\t\t});\n\n\t\tit(\"returns single snapshot with isCurrent true\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\t\t\tsession.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"test.key\", value: \"test value\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\tconst history = session.getMemoryHistory();\n\t\t\texpect(history).toHaveLength(1);\n\t\t\texpect(history[0]?.isCurrent).toBe(true);\n\t\t\texpect(history[0]?.items).toHaveLength(1);\n\t\t\texpect(history[0]?.items[0]?.key).toBe(\"test.key\");\n\t\t});\n\n\t\tit(\"returns multiple snapshots in chronological order (oldest first)\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\n\t\t\t// First snapshot\n\t\t\tsession.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"first.key\", value: \"first value\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\t// Second snapshot\n\t\t\tsession.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"first.key\", value: \"first value\", timestamp: new Date().toISOString() },\n\t\t\t\t{ key: \"second.key\", value: \"second value\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\t// Third snapshot\n\t\t\tsession.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"first.key\", value: \"first value\", timestamp: new Date().toISOString() },\n\t\t\t\t{ key: \"second.key\", value: \"second value\", timestamp: new Date().toISOString() },\n\t\t\t\t{ key: \"third.key\", value: \"third value\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\tconst history = session.getMemoryHistory();\n\t\t\texpect(history).toHaveLength(3);\n\t\t\texpect(history[0]?.items).toHaveLength(1);\n\t\t\texpect(history[0]?.items[0]?.key).toBe(\"first.key\");\n\t\t\texpect(history[1]?.items).toHaveLength(2);\n\t\t\texpect(history[2]?.items).toHaveLength(3);\n\t\t\texpect(history[2]?.isCurrent).toBe(true);\n\t\t\texpect(history[0]?.isCurrent).toBe(false);\n\t\t});\n\n\t\tit(\"only follows current branch, not sibling branches\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\n\t\t\t// First snapshot on main branch\n\t\t\tconst entry1 = session.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"main.key\", value: \"main value\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\t// Branch from entry1\n\t\t\tsession.branch(entry1);\n\n\t\t\t// Snapshot on branch\n\t\t\tsession.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"branch.key\", value: \"branch value\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\tconst history = session.getMemoryHistory();\n\t\t\t// Should include main snapshot and branch snapshot\n\t\t\texpect(history).toHaveLength(2);\n\t\t\texpect(history[0]?.items[0]?.key).toBe(\"main.key\");\n\t\t\texpect(history[1]?.items[0]?.key).toBe(\"branch.key\");\n\t\t});\n\n\t\tit(\"includes entryId and parentId in snapshots\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\t\t\tconst entry1 = session.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"key1\", value: \"value1\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\tconst entry2 = session.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"key2\", value: \"value2\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\tconst history = session.getMemoryHistory();\n\t\t\texpect(history).toHaveLength(2);\n\t\t\texpect(history[0]?.entryId).toBe(entry1);\n\t\t\texpect(history[1]?.entryId).toBe(entry2);\n\t\t\texpect(history[1]?.parentId).toBe(entry1);\n\t\t});\n\t});\n\n\tdescribe(\"findMemorySnapshotById\", () => {\n\t\tit(\"returns undefined when no snapshots exist\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\t\t\tsession.appendMessage({\n\t\t\t\trole: \"user\",\n\t\t\t\tcontent: [{ type: \"text\", text: \"hello\" }],\n\t\t\t\ttimestamp: Date.now(),\n\t\t\t});\n\t\t\texpect(session.findMemorySnapshotById(\"abc12345\")).toBeUndefined();\n\t\t});\n\n\t\tit(\"returns snapshot when ID exists in current branch\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\t\t\tconst entryId = session.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"test.key\", value: \"test value\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\tconst found = session.findMemorySnapshotById(entryId);\n\t\t\texpect(found).toBeDefined();\n\t\t\texpect(found?.entryId).toBe(entryId);\n\t\t\texpect(found?.items[0]?.key).toBe(\"test.key\");\n\t\t});\n\n\t\tit(\"returns undefined for ID not in current branch\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\n\t\t\t// Snapshot on main branch\n\t\t\tconst entry1 = session.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"main.key\", value: \"main value\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\t// Branch and add another snapshot\n\t\t\tsession.branch(entry1);\n\t\t\tsession.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"branch.key\", value: \"branch value\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\t// Entry ID from main branch IS found because getMemoryHistory walks full path from root.\n\t\t\t// To test \"not in branch\" we would need an ID from a sibling branch that was never\n\t\t\t// on our ancestor path — but without creating that entry on another branch first,\n\t\t\t// the only IDs that exist are ones on our ancestor path.\n\t\t\t// This test verifies that a non-existent ID returns undefined.\n\t\t\texpect(session.findMemorySnapshotById(\"zzzzzzzz\")).toBeUndefined();\n\t\t});\n\n\t\tit(\"returns undefined for invalid/malformed ID\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\t\t\tsession.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"test.key\", value: \"test value\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\t\t\texpect(session.findMemorySnapshotById(\"nonexistent\")).toBeUndefined();\n\t\t\texpect(session.findMemorySnapshotById(\"\")).toBeUndefined();\n\t\t});\n\n\t\tit(\"same-ID comparison yields all arrays empty via computeMemorySnapshotDiff\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\t\t\tsession.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"key1\", value: \"value1\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\tconst history = session.getMemoryHistory();\n\t\t\texpect(history).toHaveLength(1);\n\t\t\tconst snapshot = history[0]!;\n\t\t\tconst diff = computeMemorySnapshotDiff(snapshot, snapshot);\n\t\t\texpect(diff.added).toEqual([]);\n\t\t\texpect(diff.removed).toEqual([]);\n\t\t\texpect(diff.changed).toEqual([]);\n\t\t\texpect(diff.isInitialSnapshot).toBe(false);\n\t\t});\n\n\t\tit(\"resolves correct snapshot among multiple\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\n\t\t\tconst entry1 = session.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"first\", value: \"v1\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\t\t\tconst entry2 = session.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"first\", value: \"v1\", timestamp: new Date().toISOString() },\n\t\t\t\t{ key: \"second\", value: \"v2\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\t\t\tconst entry3 = session.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"first\", value: \"v1\", timestamp: new Date().toISOString() },\n\t\t\t\t{ key: \"second\", value: \"v2\", timestamp: new Date().toISOString() },\n\t\t\t\t{ key: \"third\", value: \"v3\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\tconst history = session.getMemoryHistory();\n\t\t\texpect(history).toHaveLength(3);\n\n\t\t\tconst snap1 = session.findMemorySnapshotById(entry1);\n\t\t\texpect(snap1?.items).toHaveLength(1);\n\t\t\texpect(snap1?.items[0]?.key).toBe(\"first\");\n\n\t\t\tconst snap2 = session.findMemorySnapshotById(entry2);\n\t\t\texpect(snap2?.items).toHaveLength(2);\n\t\t\texpect(snap2?.items[1]?.key).toBe(\"second\");\n\n\t\t\tconst snap3 = session.findMemorySnapshotById(entry3);\n\t\t\texpect(snap3?.items).toHaveLength(3);\n\t\t\texpect(snap3?.items[2]?.key).toBe(\"third\");\n\t\t\texpect(snap3?.isCurrent).toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"resolveMemorySnapshotSelector\", () => {\n\t\tit(\"resolves selectors only within current-branch history\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\t\t\tconst shared = session.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"shared\", value: \"root\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\t\t\tconst siblingOnly = session.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"sibling\", value: \"main\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\tsession.branch(shared);\n\t\t\tconst branchOnly = session.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"branch\", value: \"active\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\tconst branchResult = session.resolveMemorySnapshotSelector(branchOnly);\n\t\t\texpect(branchResult.snapshot?.entryId).toBe(branchOnly);\n\t\t\texpect(branchResult.error).toBeUndefined();\n\n\t\t\tconst siblingResult = session.resolveMemorySnapshotSelector(siblingOnly);\n\t\t\texpect(siblingResult.snapshot).toBeUndefined();\n\t\t\texpect(siblingResult.error).toBe(\"not_found\");\n\t\t});\n\n\t\tit(\"preserves same-snapshot compare compatibility through SessionManager\", () => {\n\t\t\tconst session = SessionManager.inMemory(\"/tmp/project\");\n\t\t\tconst entryId = session.appendCustomEntry(SESSION_MEMORY_CUSTOM_TYPE, [\n\t\t\t\t{ key: \"a\", value: \"1\", timestamp: new Date().toISOString() },\n\t\t\t]);\n\n\t\t\tconst baselineResult = session.resolveMemorySnapshotSelector(entryId);\n\t\t\tconst targetResult = session.resolveMemorySnapshotSelector(`[${entryId.slice(0, 8)}]`);\n\t\t\tconst diff = computeMemorySnapshotDiff(baselineResult.snapshot!, targetResult.snapshot!);\n\n\t\t\texpect(baselineResult.resolvedId).toBe(targetResult.resolvedId);\n\t\t\texpect(diff.added).toEqual([]);\n\t\t\texpect(diff.removed).toEqual([]);\n\t\t\texpect(diff.changed).toEqual([]);\n\t\t});\n\t});\n});\n\ndescribe(\"computeMemorySnapshotDiff\", () => {\n\tfunction makeSnapshot(\n\t\tid: string,\n\t\titems: Array<{ key: string; value: string }>,\n\t\trecordedAt = \"2024-01-01T00:00:00.000Z\",\n\t): MemoryHistorySnapshot {\n\t\treturn {\n\t\t\tentryId: id,\n\t\t\tparentId: null,\n\t\t\trecordedAt,\n\t\t\titems: items.map((i) => ({ ...i, timestamp: recordedAt })),\n\t\t\tisCurrent: false,\n\t\t};\n\t}\n\n\tit(\"empty previous vs current with items → all added\", () => {\n\t\tconst current = makeSnapshot(\"s2\", [{ key: \"a\", value: \"1\" }]);\n\t\tconst result = computeMemorySnapshotDiff(undefined, current);\n\t\texpect(result.isInitialSnapshot).toBe(true);\n\t\texpect(result.added).toEqual([{ type: \"added\", key: \"a\", value: \"1\" }]);\n\t\texpect(result.removed).toEqual([]);\n\t\texpect(result.changed).toEqual([]);\n\t});\n\n\tit(\"added only\", () => {\n\t\tconst prev = makeSnapshot(\"s1\", [{ key: \"a\", value: \"1\" }]);\n\t\tconst curr = makeSnapshot(\"s2\", [\n\t\t\t{ key: \"a\", value: \"1\" },\n\t\t\t{ key: \"b\", value: \"2\" },\n\t\t]);\n\t\tconst result = computeMemorySnapshotDiff(prev, curr);\n\t\texpect(result.isInitialSnapshot).toBe(false);\n\t\texpect(result.added).toEqual([{ type: \"added\", key: \"b\", value: \"2\" }]);\n\t\texpect(result.removed).toEqual([]);\n\t\texpect(result.changed).toEqual([]);\n\t});\n\n\tit(\"removed only\", () => {\n\t\tconst prev = makeSnapshot(\"s1\", [\n\t\t\t{ key: \"a\", value: \"1\" },\n\t\t\t{ key: \"b\", value: \"2\" },\n\t\t]);\n\t\tconst curr = makeSnapshot(\"s2\", [{ key: \"a\", value: \"1\" }]);\n\t\tconst result = computeMemorySnapshotDiff(prev, curr);\n\t\texpect(result.isInitialSnapshot).toBe(false);\n\t\texpect(result.added).toEqual([]);\n\t\texpect(result.removed).toEqual([{ type: \"removed\", key: \"b\", value: \"2\" }]);\n\t\texpect(result.changed).toEqual([]);\n\t});\n\n\tit(\"changed only\", () => {\n\t\tconst prev = makeSnapshot(\"s1\", [{ key: \"a\", value: \"1\" }]);\n\t\tconst curr = makeSnapshot(\"s2\", [{ key: \"a\", value: \"updated\" }]);\n\t\tconst result = computeMemorySnapshotDiff(prev, curr);\n\t\texpect(result.isInitialSnapshot).toBe(false);\n\t\texpect(result.added).toEqual([]);\n\t\texpect(result.removed).toEqual([]);\n\t\texpect(result.changed).toEqual([{ type: \"changed\", key: \"a\", previousValue: \"1\", currentValue: \"updated\" }]);\n\t});\n\n\tit(\"mixed added/removed/changed\", () => {\n\t\tconst prev = makeSnapshot(\"s1\", [\n\t\t\t{ key: \"a\", value: \"1\" },\n\t\t\t{ key: \"b\", value: \"old_b\" },\n\t\t\t{ key: \"gone\", value: \"val\" },\n\t\t]);\n\t\tconst curr = makeSnapshot(\"s2\", [\n\t\t\t{ key: \"a\", value: \"updated\" },\n\t\t\t{ key: \"b\", value: \"old_b\" },\n\t\t\t{ key: \"new\", value: \"added_val\" },\n\t\t]);\n\t\tconst result = computeMemorySnapshotDiff(prev, curr);\n\t\texpect(result.added).toEqual([{ type: \"added\", key: \"new\", value: \"added_val\" }]);\n\t\texpect(result.removed).toEqual([{ type: \"removed\", key: \"gone\", value: \"val\" }]);\n\t\texpect(result.changed).toEqual([{ type: \"changed\", key: \"a\", previousValue: \"1\", currentValue: \"updated\" }]);\n\t});\n\n\tit(\"unchanged keys are excluded\", () => {\n\t\tconst prev = makeSnapshot(\"s1\", [\n\t\t\t{ key: \"same\", value: \"unchanged\" },\n\t\t\t{ key: \"changed\", value: \"v1\" },\n\t\t]);\n\t\tconst curr = makeSnapshot(\"s2\", [\n\t\t\t{ key: \"same\", value: \"unchanged\" },\n\t\t\t{ key: \"changed\", value: \"v2\" },\n\t\t]);\n\t\tconst result = computeMemorySnapshotDiff(prev, curr);\n\t\t// 'same' key should not appear anywhere\n\t\texpect(result.added.map((i) => i.key)).not.toContain(\"same\");\n\t\texpect(result.removed.map((i) => i.key)).not.toContain(\"same\");\n\t\texpect(result.changed.map((i) => i.key)).not.toContain(\"same\");\n\t\t// only 'changed' appears in changed\n\t\texpect(result.changed).toEqual([{ type: \"changed\", key: \"changed\", previousValue: \"v1\", currentValue: \"v2\" }]);\n\t});\n\n\tit(\"both empty → all arrays empty\", () => {\n\t\tconst prev = makeSnapshot(\"s1\", []);\n\t\tconst curr = makeSnapshot(\"s2\", []);\n\t\tconst result = computeMemorySnapshotDiff(prev, curr);\n\t\texpect(result.isInitialSnapshot).toBe(false);\n\t\texpect(result.added).toEqual([]);\n\t\texpect(result.removed).toEqual([]);\n\t\texpect(result.changed).toEqual([]);\n\t});\n});\n"]}