diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1317f2044..230740f25 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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. diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index d7dd383ae..eef3c9650 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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. diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 168f6603d..37c465faa 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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, diff --git a/packages/coding-agent/src/memories/index.ts b/packages/coding-agent/src/memories/index.ts index 7342114fa..dd95c39db 100644 --- a/packages/coding-agent/src/memories/index.ts +++ b/packages/coding-agent/src/memories/index.ts @@ -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 { + 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 { + 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 { +async function runPhase1(options: MemoryStartupOptions): Promise { + 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 { +async function runPhase2(options: MemoryStartupOptions): Promise { + 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, diff --git a/packages/coding-agent/src/memory-backend/tool-names.ts b/packages/coding-agent/src/memory-backend/tool-names.ts new file mode 100644 index 000000000..29330f00c --- /dev/null +++ b/packages/coding-agent/src/memory-backend/tool-names.ts @@ -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; diff --git a/packages/coding-agent/src/mnemopi/backend.ts b/packages/coding-agent/src/mnemopi/backend.ts index e951434ec..f72101818 100644 --- a/packages/coding-agent/src/mnemopi/backend.ts +++ b/packages/coding-agent/src/mnemopi/backend.ts @@ -62,6 +62,20 @@ const STATIC_INSTRUCTIONS = [ "", ].join("\n"); +async function installMnemopiState(session: AgentSession, config: MnemopiBackendConfig): Promise { + 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 { 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) { diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index cafc35267..4bf5681dd 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -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)); diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 4fdc7ee7e..3ed00eaff 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -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) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index a643cc000..de04ebe4f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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; /** 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(); #providerSessionState = new Map(); #hindsightSessionState: HindsightSessionState | undefined = undefined; + #memoryAgentDir: string | undefined; + #memoryTaskDepth = 0; + #createMemoryTools: (() => Promise) | undefined; + #memoryBackendTransition: Promise = 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 { + 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 { + if (this.#isDisposed) return; + const transition = this.#memoryBackendTransition.then(() => this.#applyMemoryBackend()); + this.#memoryBackendTransition = transition.then( + () => undefined, + () => undefined, + ); + await transition; + } + + async #applyMemoryBackend(): Promise { + 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 { + const tools = (await this.#createMemoryTools?.()) ?? []; + await this.#replaceMemoryTools(tools); + } + + async #replaceMemoryTools(tools: AgentTool[]): Promise { + const removed = new Set(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 { - 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 diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 47ecadee6..fccacafa1 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -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 ( diff --git a/packages/coding-agent/test/agent-session-memory-backend.test.ts b/packages/coding-agent/test/agent-session-memory-backend.test.ts new file mode 100644 index 000000000..9f2050a18 --- /dev/null +++ b/packages/coding-agent/test/agent-session-memory-backend.test.ts @@ -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): 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([[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(); + const releaseFirst = Promise.withResolvers(); + 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); + }); +}); diff --git a/packages/coding-agent/test/memories-runtime.test.ts b/packages/coding-agent/test/memories-runtime.test.ts index 68906934b..990c0a6ad 100644 --- a/packages/coding-agent/test/memories-runtime.test.ts +++ b/packages/coding-agent/test/memories-runtime.test.ts @@ -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({ diff --git a/packages/coding-agent/test/memory-tools.test.ts b/packages/coding-agent/test/memory-tools.test.ts index 5676732b5..4a65636e6 100644 --- a/packages/coding-agent/test/memory-tools.test.ts +++ b/packages/coding-agent/test/memory-tools.test.ts @@ -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; } 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(); + 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 => { - 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(); + 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 () => { diff --git a/packages/coding-agent/test/selector-settings-side-effects.test.ts b/packages/coding-agent/test/selector-settings-side-effects.test.ts index 532d290a9..e7d558ca9 100644 --- a/packages/coding-agent/test/selector-settings-side-effects.test.ts +++ b/packages/coding-agent/test/selector-settings-side-effects.test.ts @@ -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]) {