241 lines
7.1 KiB
TypeScript
241 lines
7.1 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type {
|
|
CompressedObservation,
|
|
RawObservation,
|
|
Session,
|
|
} from "../src/types.js";
|
|
import { registerEvictFunction } from "../src/functions/evict.js";
|
|
import { KV } from "../src/state/schema.js";
|
|
|
|
vi.mock("../src/logger.js", () => ({
|
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
}));
|
|
|
|
type Store = Map<string, Map<string, unknown>>;
|
|
type Handler = (payload: unknown) => unknown | Promise<unknown>;
|
|
|
|
function daysAgo(days: number): string {
|
|
return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
}
|
|
|
|
function makeSession(id: string): Session {
|
|
return {
|
|
id,
|
|
project: "agentmemory",
|
|
cwd: "/repo/agentmemory",
|
|
startedAt: daysAgo(31),
|
|
status: "active",
|
|
observationCount: 1,
|
|
};
|
|
}
|
|
|
|
function makeObservation(sessionId: string): CompressedObservation {
|
|
return {
|
|
id: "obs_1",
|
|
sessionId,
|
|
timestamp: daysAgo(31),
|
|
type: "decision",
|
|
title: "Chose sqlite storage",
|
|
facts: ["Use sqlite for local state"],
|
|
narrative: "The session chose sqlite for local state.",
|
|
concepts: ["sqlite"],
|
|
files: ["src/state/kv.ts"],
|
|
importance: 8,
|
|
};
|
|
}
|
|
|
|
function makeRawObservation(sessionId: string): RawObservation {
|
|
return {
|
|
id: "raw_1",
|
|
sessionId,
|
|
timestamp: daysAgo(31),
|
|
hookType: "post_tool_use",
|
|
toolName: "Edit",
|
|
raw: { file_path: "src/state/kv.ts" },
|
|
};
|
|
}
|
|
|
|
function mockKV(store: Store, listFailures: Set<string> = new Set()) {
|
|
return {
|
|
get: async <T>(scope: string, key: string): Promise<T | null> =>
|
|
(store.get(scope)?.get(key) as T) ?? null,
|
|
set: async <T>(scope: string, key: string, data: T): Promise<T> => {
|
|
if (!store.has(scope)) store.set(scope, new Map());
|
|
store.get(scope)!.set(key, data);
|
|
return data;
|
|
},
|
|
delete: async (scope: string, key: string): Promise<void> => {
|
|
store.get(scope)?.delete(key);
|
|
},
|
|
list: async <T>(scope: string): Promise<T[]> => {
|
|
if (listFailures.has(scope)) {
|
|
throw new Error(`list failed for ${scope}`);
|
|
}
|
|
const entries = store.get(scope);
|
|
return entries ? (Array.from(entries.values()) as T[]) : [];
|
|
},
|
|
};
|
|
}
|
|
|
|
function mockSdk() {
|
|
const handlers = new Map<string, Handler>();
|
|
const calls: Array<{ function_id: string; payload: unknown }> = [];
|
|
return {
|
|
calls,
|
|
sdk: {
|
|
registerFunction: (functionId: string, handler: Handler) => {
|
|
handlers.set(functionId, handler);
|
|
},
|
|
trigger: async (input: { function_id: string; payload: unknown }) => {
|
|
calls.push(input);
|
|
const handler = handlers.get(input.function_id);
|
|
if (!handler) throw new Error(`missing handler: ${input.function_id}`);
|
|
return handler(input.payload);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function storeForObservations(
|
|
sessionId: string,
|
|
observations: Array<CompressedObservation | RawObservation>,
|
|
): Store {
|
|
const session = makeSession(sessionId);
|
|
return new Map([
|
|
[KV.sessions, new Map([[session.id, session]])],
|
|
[KV.summaries, new Map()],
|
|
[
|
|
KV.observations(session.id),
|
|
new Map(observations.map((observation) => [observation.id, observation])),
|
|
],
|
|
[KV.config, new Map()],
|
|
[KV.audit, new Map()],
|
|
]);
|
|
}
|
|
|
|
function storeForObservedSession(sessionId: string): Store {
|
|
return storeForObservations(sessionId, [makeObservation(sessionId)]);
|
|
}
|
|
|
|
describe("mem::evict stale sessions", () => {
|
|
it("runs session recovery before deleting a stale observed session", async () => {
|
|
const sessionId = "ses_stale";
|
|
const store = storeForObservedSession(sessionId);
|
|
const kv = mockKV(store);
|
|
const { sdk, calls } = mockSdk();
|
|
|
|
registerEvictFunction(sdk as never, kv as never);
|
|
sdk.registerFunction("event::session::stopped", async (payload) => {
|
|
expect(payload).toEqual({ sessionId });
|
|
expect(await kv.get(KV.sessions, sessionId)).toMatchObject({
|
|
id: sessionId,
|
|
});
|
|
return { success: true };
|
|
});
|
|
sdk.registerFunction("mem::consolidate-pipeline", () => ({
|
|
success: true,
|
|
}));
|
|
|
|
const result = (await sdk.trigger({
|
|
function_id: "mem::evict",
|
|
payload: {},
|
|
})) as { staleSessions: number };
|
|
|
|
expect(result.staleSessions).toBe(1);
|
|
expect(await kv.get(KV.sessions, sessionId)).toBeNull();
|
|
const audits = await kv.list<{
|
|
details: { reason: string };
|
|
}>(KV.audit);
|
|
expect(audits[0].details.reason).toBe(
|
|
"stale_session_recovered_then_evicted",
|
|
);
|
|
expect(calls.map((call) => call.function_id)).toContain(
|
|
"event::session::stopped",
|
|
);
|
|
expect(calls.map((call) => call.function_id)).toContain(
|
|
"mem::consolidate-pipeline",
|
|
);
|
|
});
|
|
|
|
it("keeps a stale observed session when recovery fails", async () => {
|
|
const sessionId = "ses_unrecovered";
|
|
const store = storeForObservedSession(sessionId);
|
|
const kv = mockKV(store);
|
|
const { sdk, calls } = mockSdk();
|
|
|
|
registerEvictFunction(sdk as never, kv as never);
|
|
sdk.registerFunction("event::session::stopped", () => ({
|
|
success: false,
|
|
error: "no_provider",
|
|
}));
|
|
|
|
const result = (await sdk.trigger({
|
|
function_id: "mem::evict",
|
|
payload: {},
|
|
})) as { staleSessions: number };
|
|
|
|
expect(result.staleSessions).toBe(0);
|
|
expect(await kv.get(KV.sessions, sessionId)).toMatchObject({
|
|
id: sessionId,
|
|
});
|
|
expect(calls.map((call) => call.function_id)).toContain(
|
|
"event::session::stopped",
|
|
);
|
|
expect(calls.map((call) => call.function_id)).not.toContain(
|
|
"mem::consolidate-pipeline",
|
|
);
|
|
});
|
|
|
|
it("keeps a stale session when observation scanning fails", async () => {
|
|
const sessionId = "ses_scan_failed";
|
|
const store = storeForObservedSession(sessionId);
|
|
const kv = mockKV(store, new Set([KV.observations(sessionId)]));
|
|
const { sdk, calls } = mockSdk();
|
|
|
|
registerEvictFunction(sdk as never, kv as never);
|
|
sdk.registerFunction("event::session::stopped", () => ({
|
|
success: true,
|
|
}));
|
|
|
|
const result = (await sdk.trigger({
|
|
function_id: "mem::evict",
|
|
payload: {},
|
|
})) as { staleSessions: number };
|
|
|
|
expect(result.staleSessions).toBe(0);
|
|
expect(await kv.get(KV.sessions, sessionId)).toMatchObject({
|
|
id: sessionId,
|
|
});
|
|
expect(calls.map((call) => call.function_id)).not.toContain(
|
|
"event::session::stopped",
|
|
);
|
|
});
|
|
|
|
it("keeps a stale session that only has raw observations", async () => {
|
|
const sessionId = "ses_raw_only";
|
|
const store = storeForObservations(sessionId, [
|
|
makeRawObservation(sessionId),
|
|
]);
|
|
const kv = mockKV(store);
|
|
const { sdk, calls } = mockSdk();
|
|
|
|
registerEvictFunction(sdk as never, kv as never);
|
|
sdk.registerFunction("event::session::stopped", () => ({
|
|
success: true,
|
|
}));
|
|
|
|
const result = (await sdk.trigger({
|
|
function_id: "mem::evict",
|
|
payload: {},
|
|
})) as { staleSessions: number };
|
|
|
|
expect(result.staleSessions).toBe(0);
|
|
expect(await kv.get(KV.sessions, sessionId)).toMatchObject({
|
|
id: sessionId,
|
|
});
|
|
expect(calls.map((call) => call.function_id)).not.toContain(
|
|
"event::session::stopped",
|
|
);
|
|
});
|
|
});
|