fix(memory): synchronized live backend lifecycle

- Serialized backend transitions across runtime state, tools, and prompts.
- Rehydrated Mnemopi listeners after clear and enqueue maintenance.
- Made memory.backend the sole post-migration local runtime gate.

Fixes #5638
This commit is contained in:
roboomp
2026-07-23 19:52:09 +00:00
parent c64e7146e0
commit 5a1f227a6b
14 changed files with 440 additions and 60 deletions
+1
View File
@@ -29,6 +29,7 @@
### Fixed
- Fixed mid-session `memory.backend` changes leaving runtime state, tools, listeners, and prompt context on different backends; Mnemopi clear/enqueue now rehydrate listeners, and legacy `memories.enabled` no longer activates the local pipeline after migration ([#5638](https://github.com/can1357/oh-my-pi/issues/5638)).
- Fixed `error.notify` raising a "Stopped with error" toast for provider failures while an auto-retry or async-delivery continuation was pending; the toast now waits for the true terminal settle.
- Fixed terminal `yield` results racing post-turn maintenance, which could trigger an unnecessary automatic handoff or compaction.
- Fixed credential-shaped tokens (GitHub/GitLab/OpenAI/Anthropic key patterns) being redacted from outbound provider requests even with `secrets.enabled` off; the pattern redaction now follows the `secrets.enabled` ("Hide Secrets") setting like the secret obfuscator.
+1 -1
View File
@@ -32,4 +32,4 @@ The agent supports three mutually-exclusive memory backends, selected via the `m
- `HINDSIGHT_RECALL_BUDGET`, `HINDSIGHT_RECALL_MAX_TOKENS` — recall sizing
- `HINDSIGHT_BANK_MISSION`, `HINDSIGHT_DEBUG`
Switching backends mid-session is honoured on the next system-prompt rebuild and the next `/memory` slash command. Existing users with `memories.enabled = true|false` are migrated to `memory.backend = "local"|"off"` exactly once on first launch.
Switching backends mid-session immediately replaces the live backend, memory tools, listeners, and system-prompt context. Existing users with `memories.enabled = true|false` are migrated to `memory.backend = "local"|"off"` exactly once on first launch; afterward, `memory.backend` is the sole runtime selector.
@@ -2493,9 +2493,8 @@ export const SETTINGS_SCHEMA = {
"memories.summaryInjectionTokenLimit": { type: "number", default: 5000 },
// Memory backend selector — picks between local memories pipeline,
// Mnemopi local SQLite, Hindsight remote memory, or off. Legacy
// `memories.enabled` keeps gating the local backend; see config/settings.ts
// migration for details.
// Mnemopi local SQLite, Hindsight remote memory, or off. The legacy
// `memories.enabled` flag is migration input only; see config/settings.ts.
"memory.backend": {
type: "enum",
values: ["off", "local", "hindsight", "mnemopi"] as const,
+40 -20
View File
@@ -142,9 +142,12 @@ export function startMemoryStartupTask(options: {
return;
}
void runMemoryStartup({ session, settings, modelRegistry, agentDir, config: cfg }).catch(error => {
logger.warn("Memory startup failed", { error: String(error) });
});
const signal = session.beginLocalMemoryStartup?.() ?? new AbortController().signal;
void runMemoryStartup({ session, settings, modelRegistry, agentDir, config: cfg, signal })
.catch(error => {
if (!signal.aborted) logger.warn("Memory startup failed", { error: String(error) });
})
.finally(() => session.endLocalMemoryStartup?.(signal));
}
interface MemoryInstructionSession {
@@ -315,26 +318,32 @@ export function enqueueMemoryConsolidation(agentDir: string, cwd: string, source
}
}
async function runMemoryStartup(options: {
interface MemoryStartupOptions {
session: AgentSession;
settings: Settings;
modelRegistry: ModelRegistry;
agentDir: string;
config: MemoryRuntimeConfig;
}): Promise<void> {
signal: AbortSignal;
}
function isMemoryStartupActive(options: MemoryStartupOptions): boolean {
return !options.signal.aborted && !options.session.isDisposed && options.settings.get("memory.backend") === "local";
}
async function runMemoryStartup(options: MemoryStartupOptions): Promise<void> {
if (!isMemoryStartupActive(options)) return;
await runPhase1(options);
if (!isMemoryStartupActive(options)) return;
await runPhase2(options);
if (!isMemoryStartupActive(options)) return;
await refreshMemoryToolDeveloperInstructionsCacheAfterStartup(options.session, options.agentDir, options.settings);
if (!isMemoryStartupActive(options)) return;
await options.session.refreshBaseSystemPrompt?.();
}
async function runPhase1(options: {
session: AgentSession;
settings: Settings;
modelRegistry: ModelRegistry;
agentDir: string;
config: MemoryRuntimeConfig;
}): Promise<void> {
async function runPhase1(options: MemoryStartupOptions): Promise<void> {
if (!isMemoryStartupActive(options)) return;
const { session, modelRegistry, agentDir, config } = options;
const db = openMemoryDb(getAgentDbPath(agentDir));
const nowSec = unixNow();
@@ -344,6 +353,7 @@ async function runPhase1(options: {
try {
const threads = await collectThreads(session, currentThreadId);
if (!isMemoryStartupActive(options)) return;
upsertThreads(db, threads);
const phase1Model = await resolveMemoryModel({
@@ -364,6 +374,7 @@ async function runPhase1(options: {
return;
}
if (!isMemoryStartupActive(options)) return;
const claims = claimStage1Jobs(db, {
nowSec,
threadScanLimit: config.threadScanLimit,
@@ -387,6 +398,7 @@ async function runPhase1(options: {
};
await runWithConcurrency(claims, config.stage1Concurrency, async claim => {
if (!isMemoryStartupActive(options)) return;
const result = await runStage1Job({
claim,
model: phase1Model,
@@ -395,6 +407,7 @@ async function runPhase1(options: {
config,
metadata: session.agent?.metadataForProvider(phase1Model.provider),
});
if (!isMemoryStartupActive(options)) return;
if (result.kind === "failed") {
logger.error("Memory phase1 stage1 job failed", {
@@ -460,13 +473,8 @@ async function runPhase1(options: {
}
}
async function runPhase2(options: {
session: AgentSession;
settings: Settings;
modelRegistry: ModelRegistry;
agentDir: string;
config: MemoryRuntimeConfig;
}): Promise<void> {
async function runPhase2(options: MemoryStartupOptions): Promise<void> {
if (!isMemoryStartupActive(options)) return;
const { session, modelRegistry, agentDir, config } = options;
const cwd = session.sessionManager.getCwd();
const db = openMemoryDb(getAgentDbPath(agentDir));
@@ -488,8 +496,10 @@ async function runPhase2(options: {
const newWatermark = computeCompletionWatermark(claim.inputWatermark, outputs);
await syncPhase2Artifacts(memoryRoot, outputs);
if (!isMemoryStartupActive(options)) return;
if (outputs.length === 0) {
await cleanupConsolidatedArtifacts(memoryRoot);
if (!isMemoryStartupActive(options)) return;
const marked = markGlobalPhase2Succeeded(db, {
ownershipToken: claim.ownershipToken,
newWatermark,
@@ -502,6 +512,7 @@ async function runPhase2(options: {
return;
}
if (!isMemoryStartupActive(options)) return;
const phase2Model = await resolveMemoryModel({
modelRegistry,
session,
@@ -529,8 +540,13 @@ async function runPhase2(options: {
return;
}
if (!isMemoryStartupActive(options)) return;
let heartbeatLostOwnership = false;
const heartbeat = setInterval(() => {
if (!isMemoryStartupActive(options)) {
clearInterval(heartbeat);
return;
}
const ok = heartbeatGlobalJob(db, {
ownershipToken: claim.ownershipToken,
leaseSeconds: config.phase2LeaseSeconds,
@@ -544,13 +560,16 @@ async function runPhase2(options: {
}, config.phase2HeartbeatSeconds * 1000);
try {
if (!isMemoryStartupActive(options)) return;
const consolidated = await runConsolidationModel({
memoryRoot,
model: phase2Model,
apiKey: modelRegistry.resolver(phase2Model, session.sessionId),
metadata: session.agent?.metadataForProvider(phase2Model.provider),
});
if (!isMemoryStartupActive(options)) return;
await applyConsolidation(memoryRoot, consolidated);
if (!isMemoryStartupActive(options)) return;
if (heartbeatLostOwnership) {
throw new Error("Phase2 lease ownership lost before completion");
}
@@ -564,6 +583,7 @@ async function runPhase2(options: {
throw new Error("Phase2 could not mark success: ownership lost");
}
} catch (error) {
if (!isMemoryStartupActive(options)) return;
markPhase2FailureWithFallback(db, {
claim,
retryDelaySeconds: config.phase2RetryDelaySeconds,
@@ -1227,7 +1247,7 @@ async function resolveMemoryModel(options: {
function loadMemoryConfig(settings: Settings): MemoryRuntimeConfig {
return {
enabled: settings.get("memory.backend") === "local" || settings.get("memories.enabled") === true,
enabled: settings.get("memory.backend") === "local",
maxRolloutsPerStartup: settings.get("memories.maxRolloutsPerStartup") ?? DEFAULTS.maxRolloutsPerStartup,
maxRolloutAgeDays: settings.get("memories.maxRolloutAgeDays") ?? DEFAULTS.maxRolloutAgeDays,
minRolloutIdleHours: settings.get("memories.minRolloutIdleHours") ?? DEFAULTS.minRolloutIdleHours,
@@ -0,0 +1,2 @@
/** Built-in tools whose availability depends on the selected memory backend. */
export const MEMORY_BACKEND_TOOL_NAMES = ["retain", "recall", "reflect", "memory_edit", "learn"] as const;
+24 -7
View File
@@ -62,6 +62,20 @@ const STATIC_INSTRUCTIONS = [
"",
].join("\n");
async function installMnemopiState(session: AgentSession, config: MnemopiBackendConfig): Promise<MnemopiSessionState> {
const state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
const previous = setMnemopiSessionState(session, state);
await previous?.dispose();
try {
state.attachSessionListeners();
return state;
} catch (error) {
setMnemopiSessionState(session, undefined);
await state.dispose({ consolidate: false });
throw error;
}
}
export const mnemopiBackend: MemoryBackend = {
id: "mnemopi",
@@ -90,10 +104,7 @@ export const mnemopiBackend: MemoryBackend = {
try {
const config = await loadMnemopiConfigWithProviders(settings, agentDir, modelRegistry, sessionId);
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
const state = new MnemopiSessionState({ sessionId, config, session });
const previous = setMnemopiSessionState(session, state);
await previous?.dispose();
state.attachSessionListeners();
await installMnemopiState(session, config);
} catch (error) {
logger.warn("Mnemopi: backend startup failed; memory backend inert.", { error: String(error) });
}
@@ -129,12 +140,19 @@ export const mnemopiBackend: MemoryBackend = {
requireMnemopiCore().resetMemoryForTests();
await Bun.sleep(0);
await removeDbFiles(getMnemopiScopedDbPaths(config));
if (!session?.sessionId || previous?.aliasOf || session.settings.get("memory.backend") !== "mnemopi") return;
try {
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
await installMnemopiState(session, config);
} catch (error) {
logger.warn("Mnemopi: clear rehydrate failed; memory backend inert.", { error: String(error) });
}
},
async enqueue(agentDir, _cwd, session): Promise<void> {
try {
let state = getMnemopiSessionState(session);
if (!state && session) {
if (!state && session?.sessionId) {
const config = await loadMnemopiConfigWithProviders(
session.settings,
agentDir,
@@ -142,8 +160,7 @@ export const mnemopiBackend: MemoryBackend = {
session.sessionId,
);
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
state = new MnemopiSessionState({ sessionId: session.sessionId, config, session });
setMnemopiSessionState(session, state);
state = await installMnemopiState(session, config);
}
await state?.consolidate({ full: true });
} catch (error) {
@@ -437,6 +437,11 @@ export class SelectorController {
this.ctx.showError(`Failed to apply personality: ${err}`);
});
break;
case "memory.backend":
void this.ctx.session.applyMemoryBackend().catch(err => {
this.ctx.showError(`Failed to apply memory backend: ${err}`);
});
break;
case "autocompleteMaxVisible":
this.ctx.editor.setAutocompleteMaxVisible(typeof value === "number" ? value : Number(value));
+11
View File
@@ -106,6 +106,7 @@ import {
} from "./mcp";
import { MCP_CONNECTION_STATUS_EVENT_CHANNEL, type McpConnectionStatusEvent } from "./mcp/startup-events";
import { createSessionMemoryRuntimeContext, resolveMemoryBackend } from "./memory-backend";
import { MEMORY_BACKEND_TOOL_NAMES } from "./memory-backend/tool-names";
import type { MnemopiSessionState } from "./mnemopi/state";
import lateDiagnosticTemplate from "./prompts/tools/lsp-late-diagnostic.md" with { type: "text" };
import { AgentLifecycleManager } from "./registry/agent-lifecycle";
@@ -3029,6 +3030,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
skillsSettings: settings.getGroup("skills"),
modelRegistry,
toolRegistry,
memoryAgentDir: agentDir,
memoryTaskDepth: taskDepth,
createMemoryTools: restrictToolNames
? undefined
: async () => {
const tools = await Promise.all(
MEMORY_BACKEND_TOOL_NAMES.map(name => BUILTIN_TOOLS[name](toolSession)),
);
return tools.filter((tool): tool is AgentTool => tool !== null);
},
createVibeTools:
(options.taskDepth ?? 0) === 0 && !options.parentTaskPrefix
? () => createVibeTools(toolSession)
@@ -256,7 +256,8 @@ import type { Goal, GoalModeState } from "../goals/state";
import type { HindsightSessionState } from "../hindsight/state";
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
import { IrcBus, type IrcMessage } from "../irc/bus";
import { resolveMemoryBackend } from "../memory-backend";
import { resolveMemoryBackend } from "../memory-backend/resolve";
import { MEMORY_BACKEND_TOOL_NAMES } from "../memory-backend/tool-names";
import { shutdownMnemopiEmbedClient } from "../mnemopi/embed-client";
import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state";
import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
@@ -936,6 +937,12 @@ export interface AgentSessionConfig {
/** Custom commands (TypeScript slash commands) */
customCommands?: LoadedCustomCommand[];
skillsSettings?: SkillsSettings;
/** Agent directory used when applying memory backend changes during a live session. */
memoryAgentDir?: string;
/** Recursion depth used to suppress live backend replacement in subagents. */
memoryTaskDepth?: number;
/** Creates the built-in memory tools allowed by the current backend selection. */
createMemoryTools?: () => Promise<AgentTool[]>;
/** Model registry for API key resolution and model discovery */
modelRegistry: ModelRegistry;
/** Tool registry for LSP and settings */
@@ -2258,6 +2265,11 @@ export class AgentSession {
#synchronouslyTerminatedYieldToolCallIds = new Set<string>();
#providerSessionState = new Map<string, ProviderSessionState>();
#hindsightSessionState: HindsightSessionState | undefined = undefined;
#memoryAgentDir: string | undefined;
#memoryTaskDepth = 0;
#createMemoryTools: (() => Promise<AgentTool[]>) | undefined;
#memoryBackendTransition: Promise<void> = Promise.resolve();
#localMemoryStartupAbort: AbortController | undefined;
readonly rawSseDebugBuffer: RawSseDebugBuffer;
#resetPromptMaintenanceState(): void {
@@ -2790,6 +2802,9 @@ export class AgentSession {
this.#skillsReloadable = config.skillsReloadable ?? true;
this.#skillsSettings = config.skillsSettings;
this.#modelRegistry = config.modelRegistry;
this.#memoryAgentDir = config.memoryAgentDir;
this.#memoryTaskDepth = config.memoryTaskDepth ?? 0;
this.#createMemoryTools = config.createMemoryTools;
// Resolve the wire service-tier per request so the Fireworks Priority
// toggle scopes priority to Fireworks alone, without mutating the shared
// session `serviceTier` that drives `/fast` and OpenAI/Anthropic priority.
@@ -6976,6 +6991,7 @@ export class AgentSession {
*/
beginDispose(): void {
this.#isDisposed = true;
this.cancelLocalMemoryStartup();
this.#titleGenerationAbortController.abort();
this.#abortAutolearnCapture();
this.#flushPendingIrcAsides();
@@ -7106,6 +7122,7 @@ export class AgentSession {
logger.warn("Post-prompt tasks still draining at dispose deadline", { error: String(error) });
}
await this.#drainAutolearnCapture();
await this.#memoryBackendTransition;
const hindsightState = this.getHindsightSessionState();
const mnemopiState = setMnemopiSessionState(this, undefined);
@@ -7845,13 +7862,126 @@ export class AgentSession {
}
}
/** Cancel the local rollout-memory startup owned by this session. */
cancelLocalMemoryStartup(): void {
this.#localMemoryStartupAbort?.abort();
this.#localMemoryStartupAbort = undefined;
}
/** Start a new local rollout-memory generation and cancel its predecessor. */
beginLocalMemoryStartup(): AbortSignal {
this.cancelLocalMemoryStartup();
const controller = new AbortController();
this.#localMemoryStartupAbort = controller;
return controller.signal;
}
/** Release the local startup slot if `signal` still owns it. */
endLocalMemoryStartup(signal: AbortSignal): void {
if (this.#localMemoryStartupAbort?.signal === signal) this.#localMemoryStartupAbort = undefined;
}
async #disposeMemoryBackendState(consolidateMnemopi = true): Promise<void> {
this.cancelLocalMemoryStartup();
const hindsight = this.getHindsightSessionState();
if (hindsight) {
try {
await hindsight.flushRetainQueue();
} catch (error) {
logger.warn("Memory lifecycle: Hindsight flush failed", { error: String(error) });
}
this.setHindsightSessionState(undefined);
hindsight.dispose();
}
const mnemopi = setMnemopiSessionState(this, undefined);
if (mnemopi) {
try {
await mnemopi.dispose({ consolidate: consolidateMnemopi });
} catch (error) {
logger.warn("Memory lifecycle: Mnemopi dispose failed", { error: String(error) });
}
}
}
/**
* Apply the selected memory backend to runtime state, tools, and prompt.
* Concurrent settings changes run in order and settle before the next turn.
*/
async applyMemoryBackend(): Promise<void> {
if (this.#isDisposed) return;
const transition = this.#memoryBackendTransition.then(() => this.#applyMemoryBackend());
this.#memoryBackendTransition = transition.then(
() => undefined,
() => undefined,
);
await transition;
}
async #applyMemoryBackend(): Promise<void> {
if (this.#isDisposed) return;
try {
await this.#disposeMemoryBackendState();
if (this.#memoryAgentDir && this.#memoryTaskDepth === 0 && !this.#isDisposed) {
const backend = await resolveMemoryBackend(this.settings);
await backend.start({
session: this,
settings: this.settings,
modelRegistry: this.#modelRegistry,
agentDir: this.#memoryAgentDir,
taskDepth: this.#memoryTaskDepth,
});
}
if (this.#isDisposed) return;
await this.#refreshMemoryTools();
if (this.#isDisposed) return;
await this.refreshBaseSystemPrompt();
} catch (error) {
await this.#disposeMemoryBackendState(false);
if (!this.#isDisposed) {
await this.#replaceMemoryTools([]).catch(refreshError => {
logger.warn("Failed to remove memory tools after backend apply error", {
error: String(refreshError),
});
});
}
throw error;
}
}
async #refreshMemoryTools(): Promise<void> {
const tools = (await this.#createMemoryTools?.()) ?? [];
await this.#replaceMemoryTools(tools);
}
async #replaceMemoryTools(tools: AgentTool[]): Promise<void> {
const removed = new Set<string>(MEMORY_BACKEND_TOOL_NAMES.filter(name => this.#builtInToolNames.has(name)));
const nextActive = this.getEnabledToolNames().filter(name => !removed.has(name));
for (const name of removed) {
this.#toolRegistry.delete(name);
this.#builtInToolNames.delete(name);
}
for (const tool of tools) {
if (!MEMORY_BACKEND_TOOL_NAMES.some(name => name === tool.name) || this.#toolRegistry.has(tool.name)) {
continue;
}
const wrapped = this.#wrapRuntimeTool(tool);
this.#toolRegistry.set(wrapped.name, wrapped);
this.#builtInToolNames.add(wrapped.name);
nextActive.push(wrapped.name);
}
await this.#applyActiveToolsByName([...new Set(nextActive)]);
}
/** Rebuild the base system prompt using the current active tool set. */
async refreshBaseSystemPrompt(): Promise<void> {
if (!this.#rebuildSystemPrompt) return;
if (this.#isDisposed || !this.#rebuildSystemPrompt) return;
const activeToolNames = this.getActiveToolNames();
this.#setActiveToolNames?.(activeToolNames);
const previousBaseSystemPrompt = this.#baseSystemPrompt;
const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
if (this.#isDisposed) return;
this.#baseSystemPrompt = built.systemPrompt;
this.#baseSystemPromptBeforeMemoryPromotion = undefined;
if (
@@ -9194,6 +9324,8 @@ export class AgentSession {
}
}
await this.#memoryBackendTransition;
if (this.#isDisposed || this.#promptGeneration !== generation) return;
const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
// Emit before_agent_start extension event
+4
View File
@@ -518,6 +518,9 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
if (!requestedTools.includes(name)) requestedTools.push(name);
}
}
if (session.settings.get("memory.backend") === "mnemopi" && !requestedTools.includes("memory_edit")) {
requestedTools.push("memory_edit");
}
// Auto-learn tools are gated by `autolearn.enabled` but, like the memory
// tools above, must also be force-included into an explicit requestedTools
// list so a restricted top-level session whose controller/guidance is
@@ -561,6 +564,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
if (name === "retain" || name === "recall" || name === "reflect") {
return ["hindsight", "mnemopi"].includes(session.settings.get("memory.backend") ?? "");
}
if (name === "memory_edit") return session.settings.get("memory.backend") === "mnemopi";
if (name === "manage_skill") return session.settings.get("autolearn.enabled") && (session.taskDepth ?? 0) === 0;
if (name === "learn") {
return (
@@ -0,0 +1,149 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import * as path from "node:path";
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { getMnemopiSessionState } from "@oh-my-pi/pi-coding-agent/mnemopi/state";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { resetMemoryForTests } from "@oh-my-pi/pi-mnemopi";
import { TempDir } from "@oh-my-pi/pi-utils";
import { type } from "arktype";
function createTool(name: string): AgentTool {
return {
name,
label: name,
description: `${name} memory tool`,
parameters: type({}),
async execute() {
return { content: [{ type: "text", text: name }] };
},
};
}
describe("AgentSession memory backend lifecycle", () => {
let authStorage: AuthStorage;
let session: AgentSession | undefined;
let settings: Settings;
let tempDir: TempDir;
beforeEach(async () => {
tempDir = TempDir.createSync("@memory-backend-lifecycle-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
settings = Settings.isolated({
"compaction.enabled": false,
"memory.backend": "off",
"mnemopi.noEmbeddings": true,
"mnemopi.llmMode": "none",
});
});
afterEach(async () => {
await session?.dispose();
session = undefined;
resetMemoryForTests();
authStorage.close();
tempDir.removeSync();
});
function createSession(createMemoryTools: () => Promise<AgentTool[]>): AgentSession {
const model = buildModel({
id: "mock",
name: "mock",
api: "openai-responses",
provider: "openai",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
});
const read = createTool("read");
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: ["initial"], tools: [read] },
streamFn: createMockModel({ responses: [{ content: ["ok"] }] }).stream,
});
const toolRegistry = new Map<string, AgentTool>([[read.name, read]]);
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(tempDir.path()),
settings,
modelRegistry: new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")),
memoryAgentDir: tempDir.path(),
memoryTaskDepth: 0,
createMemoryTools,
toolRegistry,
builtInToolNames: [read.name],
rebuildSystemPrompt: async toolNames => ({
systemPrompt: [`backend:${settings.get("memory.backend")};tools:${toolNames.sort().join(",")}`],
}),
});
return session;
}
it("switches runtime state, memory tools, and prompt in one apply", async () => {
const current = createSession(async () =>
settings.get("memory.backend") === "mnemopi" ? [createTool("retain"), createTool("memory_edit")] : [],
);
settings.override("memory.backend", "mnemopi");
await current.applyMemoryBackend();
expect(getMnemopiSessionState(current)).toBeDefined();
expect(current.getActiveToolNames()).toEqual(expect.arrayContaining(["read", "retain", "memory_edit"]));
expect(current.systemPrompt).toEqual(["backend:mnemopi;tools:memory_edit,read,retain"]);
settings.override("memory.backend", "off");
await current.applyMemoryBackend();
expect(getMnemopiSessionState(current)).toBeUndefined();
expect(current.getActiveToolNames()).toEqual(["read"]);
expect(current.getAllToolNames()).toEqual(["read"]);
expect(current.systemPrompt).toEqual(["backend:off;tools:read"]);
});
it("cancels a displaced local startup generation", async () => {
const current = createSession(async () => []);
const localStartup = current.beginLocalMemoryStartup();
await current.applyMemoryBackend();
expect(localStartup.aborted).toBe(true);
});
it("serializes concurrent backend applies", async () => {
const firstStarted = Promise.withResolvers<void>();
const releaseFirst = Promise.withResolvers<void>();
let calls = 0;
let running = 0;
let maxRunning = 0;
const current = createSession(async () => {
calls++;
running++;
maxRunning = Math.max(maxRunning, running);
if (calls === 1) {
firstStarted.resolve();
await releaseFirst.promise;
}
running--;
return [];
});
const first = current.applyMemoryBackend();
await firstStarted.promise;
const second = current.applyMemoryBackend();
await Promise.resolve();
expect(calls).toBe(1);
releaseFirst.resolve();
await Promise.all([first, second]);
expect(maxRunning).toBe(1);
expect(calls).toBe(2);
});
});
@@ -145,7 +145,7 @@ describe("memories runtime", () => {
process.env.XDG_STATE_HOME = savedXdgState;
});
test("startup gating skips when disabled or subagent depth", async () => {
test("startup gating follows memory.backend and skips subagents", async () => {
const disabled = await createFixture({ "memories.enabled": false });
const openSpy = vi.spyOn(memoryStorage, "openMemoryDb");
startMemoryStartupTask({
@@ -156,6 +156,15 @@ describe("memories runtime", () => {
taskDepth: 0,
});
expect(openSpy).not.toHaveBeenCalled();
const explicitlyOff = await createFixture({ "memory.backend": "off", "memories.enabled": true });
startMemoryStartupTask({
session: explicitlyOff.session,
settings: explicitlyOff.settings,
modelRegistry: explicitlyOff.modelRegistry,
agentDir: explicitlyOff.agentDir,
taskDepth: 0,
});
expect(openSpy).not.toHaveBeenCalled();
const subagent = await createFixture({ "memories.enabled": true });
startMemoryStartupTask({
+46 -26
View File
@@ -8,7 +8,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { existsSync, mkdirSync } from "node:fs";
import { 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";
@@ -17,13 +17,13 @@ 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 { AgentSessionEventListener } from "@oh-my-pi/pi-coding-agent/session/agent-session";
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";
@@ -32,8 +32,7 @@ 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.
// Mnemopi is lazy-loaded at runtime; preload it for synchronous state construction.
await Promise.all([loadMnemopi(), loadMnemopiCore()]);
const TEST_SESSION_ID = "test-session-id";
@@ -159,6 +158,7 @@ interface RegisterMnemopiStateOptions {
cwd?: string;
sessionId?: string;
entries?: () => unknown[];
listeners?: Set<AgentSessionEventListener>;
}
function registerMnemopiState(
@@ -172,12 +172,25 @@ function registerMnemopiState(
config: finalConfig,
session: {
sessionId,
settings: Settings.isolated({
"memory.backend": "mnemopi",
"mnemopi.noEmbeddings": true,
"mnemopi.llmMode": "none",
}),
modelRegistry: {
getApiKeyForProvider: async () => undefined,
resolver: () => async () => undefined,
} as never,
sessionManager: {
getEntries: options.entries ?? (() => []),
getCwd: () => options.cwd ?? "/tmp",
} as never,
emitNotice: () => {},
getHindsightSessionState: () => undefined,
subscribe: (listener: AgentSessionEventListener) => {
options.listeners?.add(listener);
return () => options.listeners?.delete(listener);
},
} as never,
});
setMnemopiSessionState(registeredMnemopiState.session as never, registeredMnemopiState);
@@ -838,7 +851,7 @@ describe("Mnemopi backend lifecycle", () => {
expect(parentRetainSpy).not.toHaveBeenCalled();
});
it("clears every scoped Mnemopi database for per-project-tagged mode", async () => {
it("clears scoped Mnemopi data and rehydrates active state", async () => {
const config = makeMnemopiConfig({
scoping: "per-project-tagged",
bank: "project-alpha",
@@ -846,33 +859,40 @@ describe("Mnemopi backend lifecycle", () => {
retainBank: "project-alpha",
recallBanks: ["project-alpha", "default"],
});
const state = registerMnemopiState(config, { cwd: "/work/project-alpha" });
const listeners = new Set<AgentSessionEventListener>();
const state = registerMnemopiState(config, { cwd: "/work/project-alpha", listeners });
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();
const rehydrated = getMnemopiSessionState(session);
if (!rehydrated) throw new Error("Mnemopi state was not rehydrated");
expect(rehydrated).not.toBe(state);
expect(listeners.size).toBe(1);
const remaining = await rehydrated.recallResultsScoped("clear marker");
expect(remaining.some(hit => String(hit.content).includes("clear marker"))).toBe(false);
expect(rehydrated.rememberScoped("after-clear", { source: "test", scope: "bank", extract: false })).toEqual(
expect.any(String),
);
registeredMnemopiState = rehydrated;
});
it("attaches listeners when enqueue rehydrates missing state", async () => {
const config = makeMnemopiConfig();
const listeners = new Set<AgentSessionEventListener>();
const seed = registerMnemopiState(config, { listeners });
const session = seed.session;
setMnemopiSessionState(session, undefined);
await seed.dispose({ consolidate: false });
registeredMnemopiState = undefined;
await mnemopiBackend.enqueue(path.dirname(config.dbPath), "/tmp", session);
registeredMnemopiState = getMnemopiSessionState(session);
expect(registeredMnemopiState).toBeDefined();
expect(listeners.size).toBe(1);
});
it("clear() skips consolidation before deleting the DBs (#2327 review)", async () => {
@@ -911,8 +931,8 @@ describe("Mnemopi backend lifecycle", () => {
expect(bank.sleep).not.toHaveBeenCalled();
expect(bank.close).toHaveBeenCalledTimes(1);
}
expect(getMnemopiSessionState(session)).toBeUndefined();
registeredMnemopiState = undefined;
registeredMnemopiState = getMnemopiSessionState(session);
expect(registeredMnemopiState).toBeDefined();
});
it("exposes direct mnemopi runtime status and search/save results", async () => {
@@ -66,6 +66,17 @@ describe("selector setting side effects", () => {
expect(invalidate).toHaveBeenCalledTimes(1);
expect(requestRender).toHaveBeenCalledTimes(1);
});
it("applies memory backend changes to the live session", () => {
const applyMemoryBackend = vi.fn(async () => {});
const controller = new SelectorController({
session: { applyMemoryBackend },
showError: vi.fn(),
} as unknown as InteractiveModeContext);
controller.handleSettingChange("memory.backend", "mnemopi");
expect(applyMemoryBackend).toHaveBeenCalledTimes(1);
});
for (const id of ["terminal.showImages", "showImages"]) {
for (const visible of [false, true]) {