1354 lines
52 KiB
TypeScript
1354 lines
52 KiB
TypeScript
/**
|
|
* Contract tests for the three shared memory tool factories.
|
|
*
|
|
* These exercise the public tool surface (factory gating + execute path) by
|
|
* spying on `HindsightApi.prototype.{retain, recall, reflect}` and stubbing
|
|
* Hindsight state on the fake ToolSession. We deliberately do not boot a real
|
|
* session — these tools only need a populated state accessor and Settings.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import { existsSync, mkdirSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { HindsightApi } from "@oh-my-pi/pi-coding-agent/hindsight/client";
|
|
import type { HindsightConfig } from "@oh-my-pi/pi-coding-agent/hindsight/config";
|
|
import { HindsightSessionState } from "@oh-my-pi/pi-coding-agent/hindsight/state";
|
|
import { mnemopiBackend } from "@oh-my-pi/pi-coding-agent/mnemopi/backend";
|
|
import { loadMnemopiConfig, type MnemopiBackendConfig } from "@oh-my-pi/pi-coding-agent/mnemopi/config";
|
|
import {
|
|
getMnemopiScopedDbPaths,
|
|
getMnemopiSessionState,
|
|
loadMnemopi,
|
|
loadMnemopiCore,
|
|
MnemopiSessionState,
|
|
setMnemopiSessionState,
|
|
} from "@oh-my-pi/pi-coding-agent/mnemopi/state";
|
|
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools/index";
|
|
import { MemoryEditTool } from "@oh-my-pi/pi-coding-agent/tools/memory-edit";
|
|
import { MemoryRecallTool } from "@oh-my-pi/pi-coding-agent/tools/memory-recall";
|
|
import { MemoryReflectTool } from "@oh-my-pi/pi-coding-agent/tools/memory-reflect";
|
|
import { MemoryRetainTool } from "@oh-my-pi/pi-coding-agent/tools/memory-retain";
|
|
import { resetMemoryForTests } from "@oh-my-pi/pi-mnemopi";
|
|
import { TempDir } from "@oh-my-pi/pi-utils";
|
|
|
|
// Mnemopi is lazy-loaded at runtime; preload it so the sync construction in
|
|
// registerMnemopiState() and getMnemopiScopedDbPaths() can resolve the module.
|
|
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
|
|
|
|
const TEST_SESSION_ID = "test-session-id";
|
|
let registeredState: HindsightSessionState | undefined;
|
|
let registeredMnemopiState: MnemopiSessionState | undefined;
|
|
let tempDbPath: string | undefined;
|
|
let tempDbDir: TempDir | undefined;
|
|
|
|
function makeConfig(overrides: Partial<HindsightConfig> = {}): HindsightConfig {
|
|
return {
|
|
hindsightApiUrl: "http://localhost:8888",
|
|
hindsightApiToken: null,
|
|
bankId: null,
|
|
bankIdPrefix: "",
|
|
scoping: "global",
|
|
bankMission: "",
|
|
retainMission: null,
|
|
autoRecall: true,
|
|
autoRetain: true,
|
|
retainMode: "full-session",
|
|
retainEveryNTurns: 3,
|
|
retainOverlapTurns: 2,
|
|
retainContext: "omp",
|
|
recallBudget: "mid",
|
|
recallMaxTokens: 1024,
|
|
recallTypes: ["world", "experience"],
|
|
recallContextTurns: 1,
|
|
recallMaxQueryChars: 800,
|
|
recallPromptPreamble: "preamble",
|
|
debug: false,
|
|
mentalModelsEnabled: false,
|
|
mentalModelAutoSeed: false,
|
|
mentalModelRefreshIntervalMs: 5 * 60 * 1000,
|
|
mentalModelMaxRenderChars: 16_000,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeSession(settings: Settings, sessionId: string | null = TEST_SESSION_ID): ToolSession {
|
|
return {
|
|
cwd: "/tmp",
|
|
hasUI: false,
|
|
settings,
|
|
getSessionFile: () => null,
|
|
getSessionId: () => sessionId,
|
|
getSessionSpawns: () => null,
|
|
getHindsightSessionState: () => (sessionId === TEST_SESSION_ID ? registeredState : undefined),
|
|
getMnemopiSessionState: () => (sessionId === TEST_SESSION_ID ? registeredMnemopiState : undefined),
|
|
} as unknown as ToolSession;
|
|
}
|
|
|
|
interface RegisterStateOptions {
|
|
retainTags?: string[];
|
|
recallTags?: string[];
|
|
recallTagsMatch?: "any" | "all" | "any_strict" | "all_strict";
|
|
sessionOverrides?: Record<string, unknown>;
|
|
}
|
|
|
|
function registerState(client: HindsightApi, settings?: Settings, opts: RegisterStateOptions = {}) {
|
|
registeredState = new HindsightSessionState({
|
|
sessionId: TEST_SESSION_ID,
|
|
client,
|
|
bankId: "test-bank",
|
|
retainTags: opts.retainTags,
|
|
recallTags: opts.recallTags,
|
|
recallTagsMatch: opts.recallTagsMatch,
|
|
config: makeConfig(),
|
|
session: {
|
|
sessionId: TEST_SESSION_ID,
|
|
sessionManager: { getEntries: () => [] } as never,
|
|
emitNotice: () => {},
|
|
getHindsightSessionState: () => registeredState,
|
|
...opts.sessionOverrides,
|
|
} as never,
|
|
banksSet: new Set(),
|
|
lastRetainedTurn: 0,
|
|
hasRecalledForFirstTurn: false,
|
|
});
|
|
void settings;
|
|
}
|
|
|
|
function makeMnemopiConfig(
|
|
overrides: (Partial<MnemopiBackendConfig> & Record<string, unknown>) | undefined = {},
|
|
): MnemopiBackendConfig {
|
|
if (!tempDbPath) {
|
|
tempDbDir = TempDir.createSync(`@mnemopi-test-${Date.now()}-`);
|
|
tempDbPath = tempDbDir.join("mnemopi.db");
|
|
}
|
|
return {
|
|
dbPath: tempDbPath,
|
|
bank: "test-bank",
|
|
autoRecall: true,
|
|
autoRetain: true,
|
|
polyphonicRecall: false,
|
|
enhancedRecall: false,
|
|
proactiveLinking: false,
|
|
retainEveryNTurns: 3,
|
|
recallLimit: 10,
|
|
recallContextTurns: 1,
|
|
recallMaxQueryChars: 800,
|
|
injectionTokenLimit: 1024,
|
|
debug: false,
|
|
providerOptions: {
|
|
noEmbeddings: true,
|
|
embeddingModel: undefined,
|
|
embeddingApiUrl: undefined,
|
|
embeddingApiKey: undefined,
|
|
llm: false,
|
|
},
|
|
llmMode: "none",
|
|
llmBaseUrl: undefined,
|
|
llmApiKey: undefined,
|
|
llmModel: undefined,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
interface RegisterMnemopiStateOptions {
|
|
cwd?: string;
|
|
sessionId?: string;
|
|
}
|
|
|
|
function registerMnemopiState(
|
|
config?: MnemopiBackendConfig,
|
|
options: RegisterMnemopiStateOptions = {},
|
|
): MnemopiSessionState {
|
|
const finalConfig = config ?? makeMnemopiConfig();
|
|
const sessionId = options.sessionId ?? TEST_SESSION_ID;
|
|
registeredMnemopiState = new MnemopiSessionState({
|
|
sessionId,
|
|
config: finalConfig,
|
|
session: {
|
|
sessionId,
|
|
sessionManager: {
|
|
getEntries: () => [],
|
|
getCwd: () => options.cwd ?? "/tmp",
|
|
} as never,
|
|
emitNotice: () => {},
|
|
getHindsightSessionState: () => undefined,
|
|
} as never,
|
|
});
|
|
setMnemopiSessionState(registeredMnemopiState.session as never, registeredMnemopiState);
|
|
return registeredMnemopiState;
|
|
}
|
|
|
|
describe("Hindsight tool factories", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
it("retain/recall/reflect factories return null when memory.backend !== hindsight", () => {
|
|
const settings = Settings.isolated({ "memory.backend": "local", "memories.enabled": false });
|
|
const session = makeSession(settings);
|
|
expect(MemoryRetainTool.createIf(session)).toBeNull();
|
|
expect(MemoryRecallTool.createIf(session)).toBeNull();
|
|
expect(MemoryReflectTool.createIf(session)).toBeNull();
|
|
});
|
|
|
|
it("retain/recall/reflect factories return tool instances when memory.backend === hindsight", () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const session = makeSession(settings);
|
|
expect(MemoryRetainTool.createIf(session)).toBeInstanceOf(MemoryRetainTool);
|
|
expect(MemoryRecallTool.createIf(session)).toBeInstanceOf(MemoryRecallTool);
|
|
expect(MemoryReflectTool.createIf(session)).toBeInstanceOf(MemoryReflectTool);
|
|
});
|
|
});
|
|
|
|
describe("Mnemopi tool factories", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredMnemopiState = undefined;
|
|
tempDbPath = undefined;
|
|
tempDbDir = undefined;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await registeredMnemopiState?.dispose();
|
|
registeredMnemopiState = undefined;
|
|
await tempDbDir?.remove();
|
|
tempDbDir = undefined;
|
|
tempDbPath = undefined;
|
|
});
|
|
|
|
it("memory tool factories gate on supported backends", () => {
|
|
const offSettings = Settings.isolated({ "memory.backend": "off", "memories.enabled": false });
|
|
const hindsightSettings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const localSession = makeSession(Settings.isolated({ "memory.backend": "local", "memories.enabled": false }));
|
|
expect(MemoryRetainTool.createIf(localSession)).toBeNull();
|
|
expect(MemoryRecallTool.createIf(localSession)).toBeNull();
|
|
expect(MemoryReflectTool.createIf(localSession)).toBeNull();
|
|
expect(MemoryEditTool.createIf(makeSession(offSettings))).toBeNull();
|
|
expect(MemoryEditTool.createIf(makeSession(hindsightSettings))).toBeNull();
|
|
});
|
|
|
|
it("retain/recall/reflect/edit factories return tool instances when memory.backend === mnemopi", () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
const session = makeSession(settings);
|
|
expect(MemoryRetainTool.createIf(session)).toBeInstanceOf(MemoryRetainTool);
|
|
expect(MemoryRecallTool.createIf(session)).toBeInstanceOf(MemoryRecallTool);
|
|
expect(MemoryReflectTool.createIf(session)).toBeInstanceOf(MemoryReflectTool);
|
|
expect(MemoryEditTool.createIf(session)).toBeInstanceOf(MemoryEditTool);
|
|
});
|
|
});
|
|
|
|
describe("retain.execute", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
it("queues the memory and reports success without calling the API", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
const retainBatchSpy = vi.spyOn(HindsightApi.prototype, "retainBatch").mockResolvedValue({} as never);
|
|
const retainSpy = vi.spyOn(HindsightApi.prototype, "retain").mockResolvedValue({} as never);
|
|
registerState(client, settings);
|
|
|
|
const tool = MemoryRetainTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-1", { items: [{ content: "user prefers tabs" }] });
|
|
|
|
expect(result.content[0]).toEqual({ type: "text", text: "1 memory queued." });
|
|
// Tool returns before any HTTP work happens.
|
|
expect(retainBatchSpy).not.toHaveBeenCalled();
|
|
expect(retainSpy).not.toHaveBeenCalled();
|
|
expect(registeredState?.retainQueue.depth).toBe(1);
|
|
});
|
|
|
|
it("flushes a multi-item tool call as a single retainBatch call with per-item context", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
const retainBatchSpy = vi.spyOn(HindsightApi.prototype, "retainBatch").mockResolvedValue({} as never);
|
|
registerState(client, settings, { retainTags: ["project:pi"] });
|
|
|
|
const tool = MemoryRetainTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-batch", {
|
|
items: [{ content: "fact one" }, { content: "fact two", context: "user override" }],
|
|
});
|
|
expect(result.content[0]).toEqual({ type: "text", text: "2 memories queued." });
|
|
|
|
await registeredState?.flushRetainQueue();
|
|
|
|
expect(retainBatchSpy).toHaveBeenCalledTimes(1);
|
|
const [bankId, items, options] = retainBatchSpy.mock.calls[0];
|
|
expect(bankId).toBe("test-bank");
|
|
expect(options).toEqual(expect.objectContaining({ async: true }));
|
|
expect(items).toEqual([
|
|
expect.objectContaining({
|
|
content: "fact one",
|
|
metadata: { session_id: TEST_SESSION_ID },
|
|
tags: ["project:pi"],
|
|
}),
|
|
expect.objectContaining({
|
|
content: "fact two",
|
|
context: "user override",
|
|
metadata: { session_id: TEST_SESSION_ID },
|
|
tags: ["project:pi"],
|
|
}),
|
|
]);
|
|
expect(registeredState?.retainQueue.depth).toBe(0);
|
|
});
|
|
|
|
it("emits a UI-only warning notice when the batch flush fails", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
vi.spyOn(HindsightApi.prototype, "retainBatch").mockRejectedValue(new Error("HTTP 503"));
|
|
const noticeSpy = vi.fn();
|
|
registerState(client, settings, { sessionOverrides: { emitNotice: noticeSpy } });
|
|
|
|
const tool = MemoryRetainTool.createIf(makeSession(settings))!;
|
|
await tool.execute("call-x", { items: [{ content: "doomed fact" }] });
|
|
await registeredState?.flushRetainQueue();
|
|
|
|
expect(noticeSpy).toHaveBeenCalledTimes(1);
|
|
const [level, message, source] = noticeSpy.mock.calls[0];
|
|
expect(level).toBe("warning");
|
|
expect(source).toBe("Hindsight");
|
|
expect(message).toContain("HTTP 503");
|
|
expect(message).toContain("1 memory");
|
|
});
|
|
|
|
it("throws when no per-session state is registered", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const tool = MemoryRetainTool.createIf(makeSession(settings))!;
|
|
await expect(tool.execute("call-2", { items: [{ content: "x" }] })).rejects.toThrow(/not initialised/i);
|
|
});
|
|
});
|
|
|
|
describe("retain.execute (Mnemopi backend)", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredMnemopiState = undefined;
|
|
tempDbPath = undefined;
|
|
tempDbDir = undefined;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await registeredMnemopiState?.dispose();
|
|
registeredMnemopiState = undefined;
|
|
await tempDbDir?.remove();
|
|
tempDbDir = undefined;
|
|
tempDbPath = undefined;
|
|
});
|
|
|
|
it("writes memories synchronously and returns a stored success message", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
registerMnemopiState();
|
|
|
|
const tool = MemoryRetainTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-mnemopi-1", {
|
|
items: [{ content: "user prefers tabs", context: "editor configuration" }],
|
|
});
|
|
|
|
expect(result.content[0]).toEqual({ type: "text", text: "1 memory stored." });
|
|
|
|
// Verify the memory was actually stored by recalling it
|
|
const recallTool = MemoryRecallTool.createIf(makeSession(settings))!;
|
|
const recallResult = await recallTool.execute("call-mnemopi-recall", { query: "user preferences" });
|
|
|
|
const text = (recallResult.content[0] as { text: string }).text;
|
|
expect(text).toContain("user prefers tabs");
|
|
});
|
|
|
|
it("stores multiple memories and returns correct count", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
registerMnemopiState();
|
|
|
|
const tool = MemoryRetainTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-mnemopi-multi", {
|
|
items: [
|
|
{ content: "fact one" },
|
|
{ content: "fact two", context: "additional context" },
|
|
{ content: "fact three" },
|
|
],
|
|
});
|
|
|
|
expect(result.content[0]).toEqual({ type: "text", text: "3 memories stored." });
|
|
|
|
// Verify all memories are recallable
|
|
const recallTool = MemoryRecallTool.createIf(makeSession(settings))!;
|
|
const recallResult = await recallTool.execute("call-mnemopi-recall-multi", { query: "facts" });
|
|
|
|
const text = (recallResult.content[0] as { text: string }).text;
|
|
expect(text).toContain("fact one");
|
|
expect(text).toContain("fact two");
|
|
expect(text).toContain("fact three");
|
|
});
|
|
|
|
it("isolates memories between projects when scoping is per-project", async () => {
|
|
const settings = Settings.isolated({
|
|
"memory.backend": "mnemopi",
|
|
"mnemopi.scoping": "per-project",
|
|
});
|
|
const alphaConfig = makeMnemopiConfig({ scoping: "per-project", bank: "project-alpha" });
|
|
const betaConfig = makeMnemopiConfig({ scoping: "per-project", bank: "project-beta" });
|
|
registerMnemopiState(alphaConfig, { cwd: "/work/project-alpha" });
|
|
await MemoryRetainTool.createIf(makeSession(settings))!.execute("call-mnemopi-alpha-store", {
|
|
items: [{ content: "alpha uses tabs" }],
|
|
});
|
|
await registeredMnemopiState?.dispose();
|
|
registerMnemopiState(betaConfig, { cwd: "/work/project-beta" });
|
|
const betaRecall = await MemoryRecallTool.createIf(makeSession(settings))!.execute("call-mnemopi-beta-recall", {
|
|
query: "tabs",
|
|
});
|
|
expect(betaRecall.content[0]).toEqual({ type: "text", text: "No relevant memories found." });
|
|
await registeredMnemopiState?.dispose();
|
|
registerMnemopiState(alphaConfig, { cwd: "/work/project-alpha" });
|
|
const alphaRecall = await MemoryRecallTool.createIf(makeSession(settings))!.execute("call-mnemopi-alpha-recall", {
|
|
query: "tabs",
|
|
});
|
|
expect((alphaRecall.content[0] as { text: string }).text).toContain("alpha uses tabs");
|
|
});
|
|
it("throws when no per-session Mnemopi state is registered", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
const tool = MemoryRetainTool.createIf(makeSession(settings))!;
|
|
await expect(tool.execute("call-mnemopi-no-state", { items: [{ content: "x" }] })).rejects.toThrow(
|
|
/not initialised/i,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Mnemopi backend lifecycle", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredMnemopiState = undefined;
|
|
tempDbPath = undefined;
|
|
tempDbDir = undefined;
|
|
// Close any leaked default Mnemopi instance from a prior test so its
|
|
// SQLite handle doesn't keep the next test's DB files locked on Windows.
|
|
resetMemoryForTests();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await registeredMnemopiState?.dispose();
|
|
registeredMnemopiState = undefined;
|
|
// Close the mnemopi default instance so its SQLite handle doesn't keep
|
|
// the temp DB files locked on Windows.
|
|
resetMemoryForTests();
|
|
await tempDbDir?.remove().catch(() => {});
|
|
tempDbDir = undefined;
|
|
tempDbPath = undefined;
|
|
});
|
|
|
|
it("auto-retain stores only the not-yet-retained suffix", async () => {
|
|
const entries = Array.from({ length: 4 }, (_, index) => ({
|
|
type: "message",
|
|
message: { role: "user", content: `turn ${index + 1}` },
|
|
}));
|
|
const state = registerMnemopiState(makeMnemopiConfig({ retainEveryNTurns: 2 }), {
|
|
cwd: "/work/project-alpha",
|
|
});
|
|
state.lastRetainedTurn = 2;
|
|
(state.session.sessionManager as { getEntries: () => unknown[] }).getEntries = () => entries;
|
|
const retainSpy = vi.spyOn(state, "retainMessages").mockResolvedValue();
|
|
|
|
await state.maybeRetainOnAgentEnd([{ role: "user", content: [{ type: "text", text: "turn 4" }] }] as never);
|
|
|
|
expect(retainSpy).toHaveBeenCalledTimes(1);
|
|
expect(retainSpy.mock.calls[0][0]).toEqual([
|
|
{ role: "user", content: "turn 3" },
|
|
{ role: "user", content: "turn 4" },
|
|
]);
|
|
expect(state.lastRetainedTurn).toBe(4);
|
|
});
|
|
|
|
it("retains the full transcript but extracts and embeds clean projections", async () => {
|
|
const state = registerMnemopiState(makeMnemopiConfig(), { cwd: "/work/project-alpha" });
|
|
const rememberSpy = vi.spyOn(state, "rememberInScope").mockReturnValue("memory-id");
|
|
|
|
await state.retainMessages(
|
|
[
|
|
{ role: "user", content: "I always prefer tabs" },
|
|
{ role: "assistant", content: "the parser never initializes and reorder never activates" },
|
|
{ role: "user", content: "I never use semicolons" },
|
|
],
|
|
"source-1",
|
|
);
|
|
|
|
expect(rememberSpy).toHaveBeenCalledTimes(1);
|
|
const [storedTranscript, options] = rememberSpy.mock.calls[0];
|
|
if (options === undefined) throw new Error("retainMessages did not pass remember options");
|
|
expect(storedTranscript).toContain("[role: assistant]");
|
|
expect(storedTranscript).toContain("reorder never activates");
|
|
expect(options.extract).toBe(true);
|
|
expect(options.extractEntities).toBe(true);
|
|
expect(options.extractText).toContain("I always prefer tabs");
|
|
expect(options.extractText).toContain("I never use semicolons");
|
|
expect(options.extractText).not.toContain("parser never initializes");
|
|
expect(options.embedText).toContain("I always prefer tabs");
|
|
expect(options.embedText).toContain("parser never initializes");
|
|
expect(options.embedText).toContain("I never use semicolons");
|
|
expect(options.embedText).not.toContain("[role:");
|
|
expect(options.embedText).not.toContain(":end]");
|
|
});
|
|
|
|
it("registers subagent aliases from parent Mnemopi state without Hindsight", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
const parentState = registerMnemopiState();
|
|
const childSession = {
|
|
sessionId: "child-session-id",
|
|
settings,
|
|
sessionManager: {
|
|
getEntries: () => [],
|
|
getCwd: () => "/tmp",
|
|
},
|
|
emitNotice: () => {},
|
|
} as never;
|
|
|
|
await mnemopiBackend.start({
|
|
session: childSession,
|
|
settings,
|
|
modelRegistry: {} as never,
|
|
agentDir: path.dirname(tempDbPath!),
|
|
taskDepth: 1,
|
|
parentMnemopiSessionState: parentState,
|
|
});
|
|
|
|
const childState = getMnemopiSessionState(childSession);
|
|
expect(childState?.aliasOf).toBe(parentState);
|
|
expect(childState?.getScopedRetainTarget().bank).toBe(parentState.getScopedRetainTarget().bank);
|
|
await childState?.dispose();
|
|
});
|
|
|
|
it("flushes extractions, sleeps, and closes every owned bank on session shutdown (#2320)", async () => {
|
|
const config = makeMnemopiConfig({
|
|
scoping: "per-project-tagged",
|
|
bank: "project-alpha",
|
|
globalBank: "default",
|
|
retainBank: "project-alpha",
|
|
recallBanks: ["project-alpha", "default"],
|
|
});
|
|
const state = registerMnemopiState(config, { cwd: "/work/project-alpha" });
|
|
// Seed working memory in each owned bank so the SQL consolidation path
|
|
// has rows to walk and the sleep call is not a trivial no-op.
|
|
state.rememberInScope("project-alpha note", { scope: "bank", extract: false, source: "test" });
|
|
state.globalMemory?.remember("default-bank note", { scope: "bank", extract: false, source: "test" });
|
|
|
|
const retainMemory = state.getScopedRetainTarget().memory;
|
|
const ownedMemories = [retainMemory];
|
|
if (state.globalMemory && state.globalMemory !== retainMemory) {
|
|
ownedMemories.push(state.globalMemory);
|
|
}
|
|
|
|
const retainSpy = vi.spyOn(state, "forceRetainCurrentSession").mockResolvedValue();
|
|
const perBank = ownedMemories.map(memory => ({
|
|
memory,
|
|
flush: vi.spyOn(memory, "flushExtractions"),
|
|
sleep: vi.spyOn(memory, "sleepAllSessions"),
|
|
close: vi.spyOn(memory, "close"),
|
|
}));
|
|
|
|
await state.dispose();
|
|
|
|
expect(retainSpy).toHaveBeenCalledTimes(1);
|
|
for (const bank of perBank) {
|
|
expect(bank.flush).toHaveBeenCalledTimes(1);
|
|
expect(bank.sleep).toHaveBeenCalledTimes(1);
|
|
expect(bank.sleep).toHaveBeenCalledWith(false);
|
|
expect(bank.close).toHaveBeenCalledTimes(1);
|
|
const flushedAt = bank.flush.mock.invocationCallOrder[0];
|
|
const sleptAt = bank.sleep.mock.invocationCallOrder[0];
|
|
const closedAt = bank.close.mock.invocationCallOrder[0];
|
|
expect(flushedAt).toBeLessThan(sleptAt);
|
|
expect(sleptAt).toBeLessThan(closedAt);
|
|
expect(retainSpy.mock.invocationCallOrder[0]).toBeLessThan(closedAt);
|
|
}
|
|
// State already consumed its owned resources; the afterEach hook would
|
|
// otherwise re-enter dispose on closed handles.
|
|
registeredMnemopiState = undefined;
|
|
});
|
|
|
|
it("dispose({ timeoutMs }) returns within the budget when consolidate stalls (#3641)", async () => {
|
|
const state = registerMnemopiState();
|
|
const retainMemory = state.getScopedRetainTarget().memory;
|
|
// Hold flushExtractions hostage longer than any reasonable shutdown budget
|
|
// so the race exclusively settles via the timeout branch.
|
|
const flushStall = Promise.withResolvers<void>();
|
|
let flushCalls = 0;
|
|
const flushSpy = vi.spyOn(retainMemory, "flushExtractions").mockImplementation(async () => {
|
|
flushCalls++;
|
|
await flushStall.promise;
|
|
});
|
|
const closeSpy = vi.spyOn(retainMemory, "close");
|
|
|
|
const BUDGET_MS = 100;
|
|
const start = Bun.nanoseconds();
|
|
await state.dispose({ timeoutMs: BUDGET_MS });
|
|
const elapsedMs = (Bun.nanoseconds() - start) / 1_000_000;
|
|
|
|
// Dispose must surrender within the budget (plus a generous slack); the
|
|
// in-flight consolidate is detached, not awaited.
|
|
expect(elapsedMs).toBeLessThan(BUDGET_MS * 5);
|
|
expect(elapsedMs).toBeGreaterThanOrEqual(BUDGET_MS - 10);
|
|
expect(flushSpy).toHaveBeenCalled();
|
|
expect(flushCalls).toBe(1);
|
|
// `close()` is deferred so SQLite writes don't race a closed handle.
|
|
expect(closeSpy).not.toHaveBeenCalled();
|
|
|
|
// Release the stall and confirm the deferred close runs once consolidate
|
|
// settles — i.e. the SQLite handle still ends up released eventually.
|
|
flushStall.resolve();
|
|
await Bun.sleep(50);
|
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
|
|
registeredMnemopiState = undefined;
|
|
});
|
|
|
|
it("dispose with no timeoutMs awaits consolidate to completion (#3641 — preserves #2320 contract)", async () => {
|
|
const state = registerMnemopiState();
|
|
const retainMemory = state.getScopedRetainTarget().memory;
|
|
const flushSpy = vi.spyOn(retainMemory, "flushExtractions").mockResolvedValue();
|
|
const sleepSpy = vi.spyOn(retainMemory, "sleepAllSessions");
|
|
const closeSpy = vi.spyOn(retainMemory, "close");
|
|
|
|
await state.dispose();
|
|
|
|
// Unbounded dispose still runs the full consolidate-then-close pipeline,
|
|
// matching the #2320 contract for non-shutdown callers (state replacement
|
|
// during `mnemopiBackend.start`, etc.).
|
|
expect(flushSpy).toHaveBeenCalledTimes(1);
|
|
expect(sleepSpy).toHaveBeenCalledTimes(1);
|
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
|
|
|
registeredMnemopiState = undefined;
|
|
});
|
|
|
|
it("skips consolidation when disposing an aliased subagent state (#2320)", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
const parentState = registerMnemopiState();
|
|
const parentMemory = parentState.getScopedRetainTarget().memory;
|
|
const childSession = {
|
|
sessionId: "child-session-id",
|
|
settings,
|
|
sessionManager: { getEntries: () => [], getCwd: () => "/tmp" },
|
|
emitNotice: () => {},
|
|
} as never;
|
|
await mnemopiBackend.start({
|
|
session: childSession,
|
|
settings,
|
|
modelRegistry: {} as never,
|
|
agentDir: path.dirname(tempDbPath!),
|
|
taskDepth: 1,
|
|
parentMnemopiSessionState: parentState,
|
|
});
|
|
const childState = getMnemopiSessionState(childSession);
|
|
expect(childState?.aliasOf).toBe(parentState);
|
|
|
|
const flushSpy = vi.spyOn(parentMemory, "flushExtractions");
|
|
const sleepSpy = vi.spyOn(parentMemory, "sleepAllSessions");
|
|
const closeSpy = vi.spyOn(parentMemory, "close");
|
|
const parentRetainSpy = vi.spyOn(parentState, "forceRetainCurrentSession");
|
|
|
|
await childState?.dispose();
|
|
|
|
// Alias dispose must not touch the parent's owned memories or trigger
|
|
// parent retention; the parent state outlives the subagent.
|
|
expect(flushSpy).not.toHaveBeenCalled();
|
|
expect(sleepSpy).not.toHaveBeenCalled();
|
|
expect(closeSpy).not.toHaveBeenCalled();
|
|
expect(parentRetainSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("aliased subagent enqueue still flushes and sleeps the parent's shared banks (#2327 review)", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
const parentState = registerMnemopiState();
|
|
const parentMemory = parentState.getScopedRetainTarget().memory;
|
|
const childSession = {
|
|
sessionId: "child-session-id",
|
|
settings,
|
|
sessionManager: { getEntries: () => [], getCwd: () => "/tmp" },
|
|
emitNotice: () => {},
|
|
modelRegistry: {} as never,
|
|
getMnemopiSessionState: () => getMnemopiSessionState(childSession),
|
|
} as never;
|
|
await mnemopiBackend.start({
|
|
session: childSession,
|
|
settings,
|
|
modelRegistry: {} as never,
|
|
agentDir: path.dirname(tempDbPath!),
|
|
taskDepth: 1,
|
|
parentMnemopiSessionState: parentState,
|
|
});
|
|
const childState = getMnemopiSessionState(childSession);
|
|
expect(childState?.aliasOf).toBe(parentState);
|
|
|
|
const flushSpy = vi.spyOn(parentMemory, "flushExtractions");
|
|
const sleepSpy = vi.spyOn(parentMemory, "sleepAllSessions");
|
|
const parentRetainSpy = vi.spyOn(parentState, "forceRetainCurrentSession");
|
|
const childRetainSpy = vi.spyOn(childState!, "forceRetainCurrentSession");
|
|
|
|
await mnemopiBackend.enqueue(path.dirname(tempDbPath!), "/tmp", childSession);
|
|
|
|
// /memory enqueue from a subagent must still consolidate the shared
|
|
// banks; `forceRetainCurrentSession` is the one piece that the alias
|
|
// guard short-circuits (the subagent's transcript is the parent's
|
|
// concern), but the SQL-level flush and sleep must reach every owned
|
|
// bank or the user's enqueue silently no-ops.
|
|
expect(flushSpy).toHaveBeenCalledTimes(1);
|
|
expect(sleepSpy).toHaveBeenCalledTimes(1);
|
|
expect(sleepSpy).toHaveBeenCalledWith(false);
|
|
expect(childRetainSpy).toHaveBeenCalledTimes(1);
|
|
expect(parentRetainSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("clears every scoped Mnemopi database for per-project-tagged mode", async () => {
|
|
const config = makeMnemopiConfig({
|
|
scoping: "per-project-tagged",
|
|
bank: "project-alpha",
|
|
globalBank: "default",
|
|
retainBank: "project-alpha",
|
|
recallBanks: ["project-alpha", "default"],
|
|
});
|
|
const state = registerMnemopiState(config, { cwd: "/work/project-alpha" });
|
|
state.rememberInScope("project clear marker", { scope: "bank", extract: false, source: "test" });
|
|
state.globalMemory?.remember("global clear marker", { scope: "bank", extract: false, source: "test" });
|
|
const dbPaths = getMnemopiScopedDbPaths(config);
|
|
for (const dbPath of dbPaths) expect(existsSync(dbPath)).toBe(true);
|
|
const session = state.session;
|
|
setMnemopiSessionState(session, state);
|
|
|
|
await mnemopiBackend.clear(path.dirname(config.dbPath), "/work/project-alpha", session);
|
|
|
|
// The clear() contract: all scoped DB files are deleted. On Windows under
|
|
// bun:test, SQLite handle release may lag behind the await; poll briefly
|
|
// before asserting rather than failing on a transient lock.
|
|
const assertGone = async (p: string): Promise<void> => {
|
|
for (let i = 0; i < 40; i++) {
|
|
if (!existsSync(p)) return;
|
|
await Bun.sleep(25);
|
|
}
|
|
};
|
|
for (const dbPath of dbPaths) {
|
|
await assertGone(dbPath);
|
|
await assertGone(`${dbPath}-wal`);
|
|
await assertGone(`${dbPath}-shm`);
|
|
}
|
|
// Assert state was cleared even if file deletion is still in-flight.
|
|
expect(getMnemopiSessionState(session)).toBeUndefined();
|
|
registeredMnemopiState = undefined;
|
|
});
|
|
|
|
it("clear() skips consolidation before deleting the DBs (#2327 review)", async () => {
|
|
const config = makeMnemopiConfig({
|
|
scoping: "per-project-tagged",
|
|
bank: "project-alpha",
|
|
globalBank: "default",
|
|
retainBank: "project-alpha",
|
|
recallBanks: ["project-alpha", "default"],
|
|
});
|
|
const state = registerMnemopiState(config, { cwd: "/work/project-alpha" });
|
|
const ownedMemories = [state.getScopedRetainTarget().memory];
|
|
if (state.globalMemory && state.globalMemory !== ownedMemories[0]) {
|
|
ownedMemories.push(state.globalMemory);
|
|
}
|
|
|
|
const retainSpy = vi.spyOn(state, "forceRetainCurrentSession");
|
|
const consolidateSpy = vi.spyOn(state, "consolidate");
|
|
const perBank = ownedMemories.map(memory => ({
|
|
flush: vi.spyOn(memory, "flushExtractions"),
|
|
sleep: vi.spyOn(memory, "sleepAllSessions"),
|
|
close: vi.spyOn(memory, "close"),
|
|
}));
|
|
|
|
const session = state.session;
|
|
setMnemopiSessionState(session, state);
|
|
|
|
await mnemopiBackend.clear(path.dirname(config.dbPath), "/work/project-alpha", session);
|
|
|
|
// `/memory clear` is about to delete the SQLite files: spending tokens
|
|
// and time consolidating memory that will be wiped is wasted work.
|
|
expect(retainSpy).not.toHaveBeenCalled();
|
|
expect(consolidateSpy).not.toHaveBeenCalled();
|
|
for (const bank of perBank) {
|
|
expect(bank.flush).not.toHaveBeenCalled();
|
|
expect(bank.sleep).not.toHaveBeenCalled();
|
|
expect(bank.close).toHaveBeenCalledTimes(1);
|
|
}
|
|
expect(getMnemopiSessionState(session)).toBeUndefined();
|
|
registeredMnemopiState = undefined;
|
|
});
|
|
|
|
it("exposes direct mnemopi runtime status and search/save results", async () => {
|
|
const config = makeMnemopiConfig({
|
|
scoping: "per-project-tagged",
|
|
bank: "project-alpha",
|
|
globalBank: "default",
|
|
retainBank: "project-alpha",
|
|
recallBanks: ["project-alpha", "default"],
|
|
});
|
|
const state = registerMnemopiState(config, { cwd: "/work/project-alpha" });
|
|
const session = state.session;
|
|
setMnemopiSessionState(session, state);
|
|
|
|
const save = await mnemopiBackend.save!(
|
|
{ agentDir: path.dirname(config.dbPath), cwd: "/work/project-alpha", session },
|
|
{
|
|
content: "the user prefers dark mode in their editor",
|
|
source: "test-source",
|
|
context: "editor preferences",
|
|
importance: 0.8,
|
|
},
|
|
);
|
|
expect(save).toMatchObject({ backend: "mnemopi", stored: 1, ids: [expect.any(String)] });
|
|
|
|
const status = await mnemopiBackend.status!({
|
|
agentDir: path.dirname(config.dbPath),
|
|
cwd: "/work/project-alpha",
|
|
session,
|
|
});
|
|
expect(status).toMatchObject({
|
|
backend: "mnemopi",
|
|
active: true,
|
|
writable: true,
|
|
searchable: true,
|
|
retainBank: "project-alpha",
|
|
});
|
|
expect(status.recallBanks).toEqual(expect.arrayContaining(["project-alpha", "default"]));
|
|
|
|
const search = await mnemopiBackend.search!(
|
|
{ agentDir: path.dirname(config.dbPath), cwd: "/work/project-alpha", session },
|
|
"dark mode",
|
|
);
|
|
expect(search.backend).toBe("mnemopi");
|
|
expect(search.count).toBeGreaterThan(0);
|
|
expect(search.items[0]).toMatchObject({
|
|
content: expect.stringContaining("dark mode"),
|
|
source: "test-source",
|
|
score: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("reports aborted searches and save-without-id failures", async () => {
|
|
const state = registerMnemopiState();
|
|
const session = state.session;
|
|
setMnemopiSessionState(session, state);
|
|
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
await expect(
|
|
mnemopiBackend.search!({ agentDir: "/tmp/agent", cwd: "/tmp", session }, "anything", {
|
|
signal: controller.signal,
|
|
}),
|
|
).resolves.toMatchObject({
|
|
backend: "mnemopi",
|
|
count: 0,
|
|
message: "Search aborted.",
|
|
});
|
|
|
|
const rememberSpy = vi.spyOn(state, "rememberScoped").mockReturnValue(undefined);
|
|
await expect(
|
|
mnemopiBackend.save!({ agentDir: "/tmp/agent", cwd: "/tmp", session }, { content: "memory without id" }),
|
|
).resolves.toMatchObject({
|
|
backend: "mnemopi",
|
|
stored: 0,
|
|
message: "Mnemopi did not return a stored memory id.",
|
|
});
|
|
rememberSpy.mockRestore();
|
|
});
|
|
|
|
it("derives valid project banks from the absolute project root", async () => {
|
|
const rootDir = TempDir.createSync(`@mnemopi-bank-${Date.now()}-`);
|
|
const root = rootDir.path();
|
|
const alphaCwd = path.join(root, "a", "api");
|
|
const betaCwd = path.join(root, "b", "api");
|
|
mkdirSync(alphaCwd, { recursive: true });
|
|
mkdirSync(betaCwd, { recursive: true });
|
|
try {
|
|
const base = Settings.isolated({
|
|
"memory.backend": "mnemopi",
|
|
"mnemopi.scoping": "per-project",
|
|
"mnemopi.bank": "../../bad bank name with spaces and punctuation!",
|
|
});
|
|
const alpha = loadMnemopiConfig(await base.cloneForCwd(alphaCwd), root);
|
|
const beta = loadMnemopiConfig(await base.cloneForCwd(betaCwd), root);
|
|
|
|
expect(alpha.bank).not.toBe(beta.bank);
|
|
const banks = [alpha.bank, beta.bank, alpha.globalBank, beta.globalBank].filter(
|
|
(bank): bank is string => typeof bank === "string",
|
|
);
|
|
for (const bank of banks) {
|
|
expect(bank).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
expect(bank.length).toBeLessThanOrEqual(64);
|
|
}
|
|
expect(alpha.globalBank).toBe("bad-bank-name-with-spaces-and-punctuation");
|
|
} finally {
|
|
rootDir.removeSync();
|
|
}
|
|
});
|
|
});
|
|
describe("recall.execute", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
it("returns the no-results sentinel when recall yields empty", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
vi.spyOn(HindsightApi.prototype, "recall").mockResolvedValue({ results: [] } as never);
|
|
registerState(client, settings);
|
|
|
|
const tool = MemoryRecallTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-3", { query: "anything" });
|
|
expect(result.content[0]).toEqual({ type: "text", text: "No relevant memories found." });
|
|
});
|
|
|
|
it("formats non-empty results with count + UTC timestamp header", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
vi.spyOn(HindsightApi.prototype, "recall").mockResolvedValue({
|
|
results: [
|
|
{ text: "fact one", type: "world", id: "1" },
|
|
{ text: "fact two", id: "2" },
|
|
],
|
|
} as never);
|
|
registerState(client, settings);
|
|
|
|
const tool = MemoryRecallTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-4", { query: "anything" });
|
|
const block = (result.content[0] as { text: string }).text;
|
|
expect(block).toMatch(/^Found 2 relevant memories \(as of \d{4}-\d{2}-\d{2} \d{2}:\d{2} UTC\)/);
|
|
expect(block).toContain("- fact one [world]");
|
|
expect(block).toContain("- fact two");
|
|
});
|
|
|
|
it("forwards recall tags + tagsMatch from session state when present", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
const recallSpy = vi.spyOn(HindsightApi.prototype, "recall").mockResolvedValue({ results: [] } as never);
|
|
registerState(client, settings, { recallTags: ["project:pi"], recallTagsMatch: "any" });
|
|
|
|
const tool = MemoryRecallTool.createIf(makeSession(settings))!;
|
|
await tool.execute("call-tags", { query: "anything" });
|
|
|
|
expect(recallSpy).toHaveBeenCalledWith(
|
|
"test-bank",
|
|
"anything",
|
|
expect.objectContaining({ tags: ["project:pi"], tagsMatch: "any" }),
|
|
);
|
|
});
|
|
|
|
it("rethrows underlying client errors", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
vi.spyOn(HindsightApi.prototype, "recall").mockRejectedValue(new Error("HTTP 503"));
|
|
registerState(client, settings);
|
|
|
|
const tool = MemoryRecallTool.createIf(makeSession(settings))!;
|
|
await expect(tool.execute("call-5", { query: "anything" })).rejects.toThrow(/HTTP 503/);
|
|
});
|
|
});
|
|
|
|
describe("recall.execute (Mnemopi backend)", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredMnemopiState = undefined;
|
|
tempDbPath = undefined;
|
|
tempDbDir = undefined;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await registeredMnemopiState?.dispose();
|
|
registeredMnemopiState = undefined;
|
|
await tempDbDir?.remove();
|
|
tempDbDir = undefined;
|
|
tempDbPath = undefined;
|
|
});
|
|
|
|
it("returns the no-results sentinel when empty", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
registerMnemopiState();
|
|
|
|
const tool = MemoryRecallTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-mnemopi-empty", { query: "nonexistent query" });
|
|
|
|
expect(result.content[0]).toEqual({ type: "text", text: "No relevant memories found." });
|
|
});
|
|
|
|
it("returns a populated text block when a retained memory exists", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
registerMnemopiState();
|
|
|
|
// First, store a memory
|
|
const retainTool = MemoryRetainTool.createIf(makeSession(settings))!;
|
|
await retainTool.execute("call-mnemopi-store", {
|
|
items: [{ content: "the user prefers dark mode in their editor" }],
|
|
});
|
|
|
|
// Then recall it
|
|
const recallTool = MemoryRecallTool.createIf(makeSession(settings))!;
|
|
const result = await recallTool.execute("call-mnemopi-query", { query: "editor preferences" });
|
|
|
|
const text = (result.content[0] as { text: string }).text;
|
|
expect(text).toMatch(/\(id: [^)]+\)/);
|
|
expect(text).toContain("Found 1 relevant memory");
|
|
expect(text).toContain("the user prefers dark mode in their editor");
|
|
});
|
|
|
|
it("shares memories across projects when scoping is global", async () => {
|
|
const settings = Settings.isolated({
|
|
"memory.backend": "mnemopi",
|
|
"mnemopi.scoping": "global",
|
|
});
|
|
const config = makeMnemopiConfig({ scoping: "global", bank: "default" });
|
|
registerMnemopiState(config, { cwd: "/work/project-alpha" });
|
|
await MemoryRetainTool.createIf(makeSession(settings))!.execute("call-mnemopi-global-store", {
|
|
items: [{ content: "global memory survives project switches" }],
|
|
});
|
|
registeredMnemopiState?.dispose();
|
|
registerMnemopiState(config, { cwd: "/work/project-beta" });
|
|
const result = await MemoryRecallTool.createIf(makeSession(settings))!.execute("call-mnemopi-global-recall", {
|
|
query: "project switches",
|
|
});
|
|
const text = (result.content[0] as { text: string }).text;
|
|
expect(text).toContain("global memory survives project switches");
|
|
});
|
|
|
|
it("merges global and project-local memories on recall when scoping is per-project-tagged", async () => {
|
|
const settings = Settings.isolated({
|
|
"memory.backend": "mnemopi",
|
|
"mnemopi.scoping": "per-project-tagged",
|
|
});
|
|
// Store a global memory (uses default/global bank)
|
|
registerMnemopiState(makeMnemopiConfig({ scoping: "global", bank: "default", globalBank: "default" }), {
|
|
cwd: "/work/project-alpha",
|
|
});
|
|
await MemoryRetainTool.createIf(makeSession(settings))!.execute("call-mnemopi-tagged-global", {
|
|
items: [{ content: "the user likes concise CLI output" }],
|
|
});
|
|
// Store project-alpha local memory
|
|
registeredMnemopiState?.dispose();
|
|
registerMnemopiState(
|
|
makeMnemopiConfig({ scoping: "per-project-tagged", bank: "project-alpha", globalBank: "default" }),
|
|
{ cwd: "/work/project-alpha" },
|
|
);
|
|
await MemoryRetainTool.createIf(makeSession(settings))!.execute("call-mnemopi-tagged-local", {
|
|
items: [{ content: "project alpha uses pnpm workspaces" }],
|
|
});
|
|
// Store project-beta local memory
|
|
registeredMnemopiState?.dispose();
|
|
registerMnemopiState(
|
|
makeMnemopiConfig({ scoping: "per-project-tagged", bank: "project-beta", globalBank: "default" }),
|
|
{ cwd: "/work/project-beta" },
|
|
);
|
|
await MemoryRetainTool.createIf(makeSession(settings))!.execute("call-mnemopi-tagged-other", {
|
|
items: [{ content: "project beta deploys to staging first" }],
|
|
});
|
|
// Recall from project-alpha should merge global + alpha, exclude beta
|
|
registeredMnemopiState?.dispose();
|
|
registerMnemopiState(
|
|
makeMnemopiConfig({ scoping: "per-project-tagged", bank: "project-alpha", globalBank: "default" }),
|
|
{ cwd: "/work/project-alpha" },
|
|
);
|
|
const result = await MemoryRecallTool.createIf(makeSession(settings))!.execute("call-mnemopi-tagged-recall", {
|
|
query: "what should I know about this user and project alpha?",
|
|
});
|
|
const text = (result.content[0] as { text: string }).text;
|
|
expect(text).toContain("the user likes concise CLI output");
|
|
expect(text).toContain("project alpha uses pnpm workspaces");
|
|
expect(text).not.toContain("project beta deploys to staging first");
|
|
});
|
|
|
|
it("throws when no per-session Mnemopi state is registered", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
const tool = MemoryRecallTool.createIf(makeSession(settings))!;
|
|
await expect(tool.execute("call-mnemopi-no-state", { query: "anything" })).rejects.toThrow(/not initialised/i);
|
|
});
|
|
});
|
|
|
|
describe("memory_edit.execute (Mnemopi backend)", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredMnemopiState = undefined;
|
|
tempDbPath = undefined;
|
|
tempDbDir = undefined;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await registeredMnemopiState?.dispose();
|
|
registeredMnemopiState = undefined;
|
|
await tempDbDir?.remove();
|
|
tempDbDir = undefined;
|
|
tempDbPath = undefined;
|
|
});
|
|
|
|
async function retainAndRecallId(settings: Settings, content: string, query: string): Promise<string> {
|
|
await MemoryRetainTool.createIf(makeSession(settings))!.execute("call-memory-edit-store", {
|
|
items: [{ content }],
|
|
});
|
|
const id = (await registeredMnemopiState?.recallResultsScoped(query))?.[0]?.id;
|
|
expect(id).toBeString();
|
|
return id!;
|
|
}
|
|
|
|
it("updates a working memory by recall id", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
registerMnemopiState();
|
|
const id = await retainAndRecallId(settings, "editor accent color is blue", "accent color");
|
|
|
|
const result = await MemoryEditTool.createIf(makeSession(settings))!.execute("call-memory-edit-update", {
|
|
op: "update",
|
|
id,
|
|
content: "editor accent color is green",
|
|
importance: 2,
|
|
});
|
|
|
|
expect((result.content[0] as { text: string }).text).toContain("updated");
|
|
const recalled = await registeredMnemopiState!.recallResultsScoped("accent color");
|
|
expect(recalled.map(memory => memory.content)).toContain("editor accent color is green");
|
|
});
|
|
|
|
it("forgets a working memory by recall id", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
registerMnemopiState();
|
|
const id = await retainAndRecallId(settings, "temporary deployment note can be deleted", "deployment note");
|
|
|
|
const result = await MemoryEditTool.createIf(makeSession(settings))!.execute("call-memory-edit-forget", {
|
|
op: "forget",
|
|
id,
|
|
});
|
|
|
|
expect((result.content[0] as { text: string }).text).toContain("deleted");
|
|
const recalled = await registeredMnemopiState!.recallResultsScoped("deployment note");
|
|
expect(recalled.map(memory => memory.content)).not.toContain("temporary deployment note can be deleted");
|
|
});
|
|
|
|
it("invalidates a working memory by recall id", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
registerMnemopiState();
|
|
const id = await retainAndRecallId(settings, "stale api key rotation policy", "api key rotation");
|
|
|
|
const result = await MemoryEditTool.createIf(makeSession(settings))!.execute("call-memory-edit-invalidate", {
|
|
op: "invalidate",
|
|
id,
|
|
});
|
|
|
|
expect((result.content[0] as { text: string }).text).toContain("invalidated");
|
|
const recalled = await registeredMnemopiState!.recallResultsScoped("api key rotation");
|
|
expect(recalled.map(memory => memory.content)).not.toContain("stale api key rotation policy");
|
|
});
|
|
|
|
it("reports not_found for unknown ids", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
registerMnemopiState();
|
|
|
|
const result = await MemoryEditTool.createIf(makeSession(settings))!.execute("call-memory-edit-missing", {
|
|
op: "forget",
|
|
id: "missing-memory-id",
|
|
});
|
|
|
|
expect(result.details).toEqual({ status: "not_found" });
|
|
expect((result.content[0] as { text: string }).text).toContain("not found");
|
|
});
|
|
|
|
it("throws when no per-session Mnemopi state is registered", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
const tool = MemoryEditTool.createIf(makeSession(settings))!;
|
|
await expect(tool.execute("call-memory-edit-no-state", { op: "forget", id: "anything" })).rejects.toThrow(
|
|
/not initialised/i,
|
|
);
|
|
});
|
|
|
|
it("renders backend stats and diagnostics for scoped banks", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
const state = registerMnemopiState();
|
|
await retainAndRecallId(settings, "stats fixture memory for mnemopi", "stats fixture");
|
|
|
|
const stats = await mnemopiBackend.stats?.("/tmp/agent", "/tmp", state.session);
|
|
const diagnose = await mnemopiBackend.diagnose?.("/tmp/agent", "/tmp", state.session);
|
|
|
|
expect(stats).toContain("# Mnemopi Memory Stats");
|
|
expect(stats).toContain("test-bank");
|
|
expect(diagnose).toContain("# Mnemopi Memory Diagnostics");
|
|
expect(diagnose).toContain("test-bank");
|
|
});
|
|
});
|
|
|
|
describe("reflect.execute", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
registeredState = undefined;
|
|
});
|
|
|
|
it("returns the reflect text and forwards context", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
const reflectSpy = vi
|
|
.spyOn(HindsightApi.prototype, "reflect")
|
|
.mockResolvedValue({ text: "Synthesised answer" } as never);
|
|
registerState(client, settings);
|
|
|
|
const tool = MemoryReflectTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-6", { query: "what does the user prefer?", context: "background" });
|
|
expect(reflectSpy).toHaveBeenCalledWith(
|
|
"test-bank",
|
|
"what does the user prefer?",
|
|
expect.objectContaining({ context: "background", budget: "mid" }),
|
|
);
|
|
expect((result.content[0] as { text: string }).text).toBe("Synthesised answer");
|
|
});
|
|
|
|
it("falls back to a sentinel when reflect returns blank text", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "hindsight" });
|
|
const client = new HindsightApi({ baseUrl: "http://localhost:8888" });
|
|
vi.spyOn(HindsightApi.prototype, "reflect").mockResolvedValue({ text: " " } as never);
|
|
registerState(client, settings);
|
|
|
|
const tool = MemoryReflectTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-7", { query: "anything" });
|
|
expect((result.content[0] as { text: string }).text).toBe("No relevant information found to reflect on.");
|
|
});
|
|
});
|
|
|
|
describe("reflect.execute (Mnemopi backend)", () => {
|
|
beforeEach(() => {
|
|
resetSettingsForTest();
|
|
registeredMnemopiState = undefined;
|
|
tempDbPath = undefined;
|
|
tempDbDir = undefined;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
await registeredMnemopiState?.dispose();
|
|
registeredMnemopiState = undefined;
|
|
await tempDbDir?.remove();
|
|
tempDbDir = undefined;
|
|
tempDbPath = undefined;
|
|
});
|
|
|
|
it("returns the no-results sentinel when empty", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
registerMnemopiState();
|
|
|
|
const tool = MemoryReflectTool.createIf(makeSession(settings))!;
|
|
const result = await tool.execute("call-mnemopi-reflect-empty", {
|
|
query: "what does the user prefer?",
|
|
});
|
|
|
|
expect(result.content[0]).toEqual({
|
|
type: "text",
|
|
text: "No relevant information found to reflect on.",
|
|
});
|
|
});
|
|
|
|
it("returns a synthesized text block based on recalled memories when data exists", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
registerMnemopiState();
|
|
|
|
// First, store memories
|
|
const retainTool = MemoryRetainTool.createIf(makeSession(settings))!;
|
|
await retainTool.execute("call-mnemopi-store-reflect", {
|
|
items: [
|
|
{ content: "the user prefers dark mode in their editor" },
|
|
{ content: "the user uses Vim keybindings" },
|
|
{ content: "the user likes tabs over spaces" },
|
|
],
|
|
});
|
|
|
|
// Then reflect on them
|
|
const reflectTool = MemoryReflectTool.createIf(makeSession(settings))!;
|
|
const result = await reflectTool.execute("call-mnemopi-reflect-query", {
|
|
query: "what are the user's editor preferences?",
|
|
});
|
|
|
|
const text = (result.content[0] as { text: string }).text;
|
|
expect(text).toContain("Based on recalled memories");
|
|
expect(text).toContain("dark mode");
|
|
expect(text).toContain("Vim");
|
|
expect(text).toContain("tabs");
|
|
});
|
|
|
|
it("includes additional context in the query when provided", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
registerMnemopiState();
|
|
|
|
// Store a memory
|
|
const retainTool = MemoryRetainTool.createIf(makeSession(settings))!;
|
|
await retainTool.execute("call-mnemopi-store-context", {
|
|
items: [{ content: "the user works on Python projects" }],
|
|
});
|
|
|
|
// Reflect with context
|
|
const reflectTool = MemoryReflectTool.createIf(makeSession(settings))!;
|
|
const result = await reflectTool.execute("call-mnemopi-reflect-context", {
|
|
query: "what does the user work on?",
|
|
context: "this is for a new project setup",
|
|
});
|
|
|
|
const text = (result.content[0] as { text: string }).text;
|
|
expect(text).toContain("Based on recalled memories");
|
|
expect(text).toContain("Python");
|
|
});
|
|
|
|
it("merges global and project-local memories on reflect when scoping is per-project-tagged", async () => {
|
|
const settings = Settings.isolated({
|
|
"memory.backend": "mnemopi",
|
|
"mnemopi.scoping": "per-project-tagged",
|
|
});
|
|
// Store a global memory (uses default/global bank)
|
|
registerMnemopiState(makeMnemopiConfig({ scoping: "global", bank: "default", globalBank: "default" }), {
|
|
cwd: "/work/project-alpha",
|
|
});
|
|
await MemoryRetainTool.createIf(makeSession(settings))!.execute("call-mnemopi-reflect-global", {
|
|
items: [{ content: "the user prefers concise summaries" }],
|
|
});
|
|
// Store project-alpha local memory
|
|
registeredMnemopiState?.dispose();
|
|
registerMnemopiState(
|
|
makeMnemopiConfig({ scoping: "per-project-tagged", bank: "project-alpha", globalBank: "default" }),
|
|
{ cwd: "/work/project-alpha" },
|
|
);
|
|
await MemoryRetainTool.createIf(makeSession(settings))!.execute("call-mnemopi-reflect-local", {
|
|
items: [{ content: "project alpha uses turbo for task orchestration" }],
|
|
});
|
|
const result = await MemoryReflectTool.createIf(makeSession(settings))!.execute("call-mnemopi-reflect-tagged", {
|
|
query: "what matters for this user working in project alpha?",
|
|
});
|
|
const text = (result.content[0] as { text: string }).text;
|
|
expect(text).toContain("Based on recalled memories");
|
|
expect(text).toContain("the user prefers concise summaries");
|
|
expect(text).toContain("project alpha uses turbo for task orchestration");
|
|
});
|
|
|
|
it("throws when no per-session Mnemopi state is registered", async () => {
|
|
const settings = Settings.isolated({ "memory.backend": "mnemopi" });
|
|
const tool = MemoryReflectTool.createIf(makeSession(settings))!;
|
|
await expect(tool.execute("call-mnemopi-reflect-no-state", { query: "anything" })).rejects.toThrow(
|
|
/not initialised/i,
|
|
);
|
|
});
|
|
});
|