// Active Memory tests cover index plugin behavior. import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; import { createPluginStateKeyedStoreForTests, resetPluginStateStoreForTests, } from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; import { parseSqliteSessionFileMarker } from "openclaw/plugin-sdk/session-store-runtime"; import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { applyCliRuntimeRecallTimeoutDefault } from "./config.js"; import plugin, { testing } from "./index.js"; import { resolveActiveRecallForRun } from "./recall-state.js"; import { hasRememberAcrossConversationsAgent } from "./session-policy.js"; // Match only lone surrogates so valid supplementary-plane characters remain allowed. const UNPAIRED_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { try { await fs.access(targetPath); } catch (error) { expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); return; } throw new Error(`expected missing path ${targetPath}`); } async function expectSingleTranscriptArtifact(directory: string): Promise { const files = await fs.readdir(directory); expect(files).toEqual([expect.stringMatching(/^active-memory-[a-z0-9]+-[a-f0-9]{8}\.jsonl$/)]); const filename = files[0]; if (!filename) { throw new Error(`expected active-memory transcript in ${directory}`); } return path.join(directory, filename); } const hoisted = vi.hoisted(() => { const sessionStore: Record> = { "agent:main:main": { sessionId: "s-main", updatedAt: 0, }, }; return { closeActiveMemorySearchManager: vi.fn(async () => {}), getActiveMemorySearchManager: vi.fn(async () => ({ manager: null })), cleanupSessionLifecycleArtifacts: vi.fn(), patchSessionEntry: vi.fn(), rawDeltaReads: [] as Array<{ maxBytes?: number; maxEvents?: number; sessionId: string }>, runtimeTranscriptFiles: {} as Record, sessionStore, updateSessionStore: vi.fn( async ( _storePath: string, updater: (store: Record>) => void, ) => { updater(sessionStore); }, ), }; }); vi.mock("openclaw/plugin-sdk/memory-host-search", () => ({ closeActiveMemorySearchManager: hoisted.closeActiveMemorySearchManager, getActiveMemorySearchManager: hoisted.getActiveMemorySearchManager, })); vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { const actual = await vi.importActual( "openclaw/plugin-sdk/session-store-runtime", ); return { ...actual, cleanupSessionLifecycleArtifacts: hoisted.cleanupSessionLifecycleArtifacts, patchSessionEntry: hoisted.patchSessionEntry, updateSessionStore: hoisted.updateSessionStore, }; }); vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async () => { const actual = await vi.importActual< typeof import("openclaw/plugin-sdk/session-transcript-runtime") >("openclaw/plugin-sdk/session-transcript-runtime"); return { ...actual, readSessionTranscriptRawDelta: async ( params: Parameters[0], ) => { hoisted.rawDeltaReads.push({ maxBytes: params.maxBytes, maxEvents: params.maxEvents, sessionId: params.sessionId, }); const testSessionFile = hoisted.runtimeTranscriptFiles[params.sessionId]; if (!testSessionFile) { return await actual.readSessionTranscriptRawDelta(params); } try { const lines = (await fs.readFile(testSessionFile, "utf8")) .split("\n") .map((line) => line.trim()) .filter(Boolean); const maxEvents = params.maxEvents ?? lines.length; const maxBytes = params.maxBytes ?? Number.MAX_SAFE_INTEGER; const events: Array<{ event: unknown; seq: number }> = []; let serializedBytes = 0; for (const [seq, line] of lines.entries()) { const nextBytes = Buffer.byteLength(`${line}\n`, "utf8"); if (events.length >= maxEvents || serializedBytes + nextBytes > maxBytes) { break; } events.push({ event: JSON.parse(line) as unknown, seq }); serializedBytes += nextBytes; } const requiredBytes = events.length === 0 && lines[0] ? Buffer.byteLength(`${lines[0]}\n`, "utf8") : undefined; return { kind: "page" as const, cursor: `test-runtime:${String(events.length)}`, events, hasMore: events.length < lines.length, ...(requiredBytes !== undefined ? { requiredBytes } : {}), serializedBytes, }; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } } return await actual.readSessionTranscriptRawDelta(params); }, readSessionTranscriptEvents: async ( target: Parameters[0], ) => { const testSessionFile = hoisted.runtimeTranscriptFiles[target.sessionId]; if (testSessionFile) { try { const content = await fs.readFile(testSessionFile, "utf8"); return content .split("\n") .map((line) => line.trim()) .filter(Boolean) .flatMap((line) => { try { return [JSON.parse(line) as unknown]; } catch { return []; } }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } } } return await actual.readSessionTranscriptEvents(target); }, }; }); describe("active-memory plugin", () => { it("removes an injected Context block from the retrieval query", () => { const prompt = `what should I pack?\n\n${testing.buildPromptPrefix("User prefers aisle seats.")}`; const query = testing.buildSearchQuery({ latestUserMessage: prompt }); expect(query).toBe("what should I pack?"); expect(query).not.toContain("Context:"); expect(query).not.toContain("User prefers aisle seats."); }); it("keeps user-authored lines that merely start with Context", () => { const query = testing.buildSearchQuery({ latestUserMessage: "Context: my project uses TypeScript", }); expect(query).toBe("Context: my project uses TypeScript"); }); it("keeps previous-message query context UTF-16 well-formed", () => { const query = testing.buildSearchQuery({ latestUserMessage: "why?", recentTurns: [{ role: "user", text: `${"x".repeat(119)}🚀tail` }], }); expect(query).toBe(`${"x".repeat(119)} why?`); expect(query).not.toMatch(UNPAIRED_SURROGATE_RE); }); const hooks: Record = {}; const requireHook = (name: string): Function => expectDefined(hooks[name], `active-memory ${name} hook registration`); const hookOptions: Record | undefined> = {}; const registeredCommands: Record = {}; const runEmbeddedAgent = vi.fn(); // Default: recall routes are not CLI-dispatch-eligible; tests that prove the // raised budget override this per-case. const resolveCliBackendDispatchEligibility = vi.fn( () => undefined as { provider: string } | undefined, ); const runtimeRunEmbeddedAgent = vi.fn(async (params: Record) => { const sessionId = typeof params.sessionId === "string" && params.sessionId.length > 0 ? params.sessionId : "unknown"; const testSessionFile = path.join(stateDir, `${sessionId}.jsonl`); hoisted.runtimeTranscriptFiles[sessionId] = testSessionFile; const result = await runEmbeddedAgent({ ...params, sessionFile: testSessionFile }); if (typeof result !== "object" || result === null || Array.isArray(result)) { return result; } const resultRecord = result as Record; const meta = typeof resultRecord.meta === "object" && resultRecord.meta !== null && !Array.isArray(resultRecord.meta) ? (resultRecord.meta as Record) : {}; const agentMeta = typeof meta.agentMeta === "object" && meta.agentMeta !== null && !Array.isArray(meta.agentMeta) ? (meta.agentMeta as Record) : {}; return { ...resultRecord, meta: { ...meta, agentMeta: { sessionFile: testSessionFile, ...agentMeta, }, }, }; }); let fixtureRoot = ""; let pluginStateDir = ""; let stateDir = ""; let configFile: Record = {}; let pluginConfig: Record = { agents: ["main"], mode: "always", logging: true, }; let apiConfig: Record = {}; const syncRuntimePluginConfig = (nextPluginConfig: Record) => { pluginConfig = { mode: "always", ...nextPluginConfig }; const plugins = configFile.plugins as Record | undefined; const entries = plugins?.entries as Record | undefined; const existingEntry = entries?.["active-memory"] as Record | undefined; configFile = { ...configFile, plugins: { ...plugins, entries: { ...entries, "active-memory": { ...existingEntry, enabled: true, config: pluginConfig, }, }, }, }; }; const setMemorySlot = (memory: string) => { const plugins = configFile.plugins as Record | undefined; configFile = { ...configFile, plugins: { ...plugins, slots: { ...(plugins?.slots as Record | undefined), memory, }, }, }; }; const api: any = { get pluginConfig() { return pluginConfig; }, set pluginConfig(nextPluginConfig: Record) { syncRuntimePluginConfig(nextPluginConfig); }, get config() { return apiConfig; }, set config(nextConfig: Record) { apiConfig = nextConfig; const plugins = configFile.plugins; configFile = { ...nextConfig, ...(plugins ? { plugins } : {}), }; }, id: "active-memory", name: "Active Memory", logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }, runtime: { agent: { runEmbeddedAgent: runtimeRunEmbeddedAgent, resolveCliBackendDispatchEligibility, session: { resolveStorePath: vi.fn(() => path.join(stateDir, "sessions.json")), loadSessionStore: vi.fn(() => hoisted.sessionStore), saveSessionStore: vi.fn(async () => {}), getSessionEntry: vi.fn( (params: { sessionKey: string }) => hoisted.sessionStore[params.sessionKey], ), listSessionEntries: vi.fn(() => Object.entries(hoisted.sessionStore).map(([sessionKey, entry]) => ({ sessionKey, entry, })), ), upsertSessionEntry: vi.fn( async (params: { sessionKey: string; entry: Record }) => { hoisted.sessionStore[params.sessionKey] = { ...params.entry }; }, ), patchSessionEntry: vi.fn( async (params: { sessionKey: string; fallbackEntry?: Record; update: (entry: Record) => Record | null; }) => { let result: Record | null = null; await hoisted.updateSessionStore( path.join(stateDir, "sessions.json"), (store: Record>) => { const existing = store[params.sessionKey] ?? params.fallbackEntry; if (!existing) { return; } const patch = params.update({ ...existing }); if (!patch) { result = existing; return; } const next = { ...existing, ...patch }; store[params.sessionKey] = next; result = next; }, ); return result; }, ), }, }, state: { resolveStateDir: () => stateDir, openKeyedStore: (options: OpenKeyedStoreOptions) => createPluginStateKeyedStoreForTests("active-memory", { ...options, env: { ...process.env, OPENCLAW_STATE_DIR: pluginStateDir }, }), }, config: { current: () => configFile, loadConfig: () => configFile, mutateConfigFile: vi.fn( async ({ mutate }: { mutate: (draft: Record) => void }) => { const draft = structuredClone(configFile); mutate(draft); configFile = draft; return { changed: true, config: configFile }; }, ), replaceConfigFile: vi.fn( async ({ nextConfig }: { nextConfig: Record }) => { configFile = nextConfig; }, ), writeConfigFile: vi.fn(async (nextConfig: Record) => { configFile = nextConfig; }), }, }, registerCommand: vi.fn((command) => { registeredCommands[command.name] = command; }), on: vi.fn((hookName: string, handler: Function, opts?: Record) => { hooks[hookName] = handler; hookOptions[hookName] = opts; }), }; const getActiveMemoryLines = (sessionKey: string): string[] => { const entries = hoisted.sessionStore[sessionKey]?.pluginDebugEntries as | Array<{ pluginId?: string; lines?: string[] }> | undefined; return entries?.find((entry) => entry.pluginId === "active-memory")?.lines ?? []; }; const expectLinesToContain = (lines: string[], text: string) => { expect(lines.join("\n")).toContain(text); }; const expectLinesNotToContain = (lines: string[], text: string) => { expect(lines.join("\n")).not.toContain(text); }; const writeTranscriptJsonl = async (sessionFile: string, records: unknown[], suffix = "\n") => { await fs.mkdir(path.dirname(sessionFile), { recursive: true }); await fs.writeFile( sessionFile, `${records.map((record) => JSON.stringify(record)).join("\n")}${suffix}`, "utf8", ); }; const writeUsableMemoryTranscript = async (sessionFile: string, text: string) => { await writeTranscriptJsonl(sessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { results: [{ text }] }, content: [{ type: "text", text: JSON.stringify({ results: [{ text }] }) }], }, }, ]); }; const waitForAbort = async (abortSignal?: AbortSignal): Promise => { if (abortSignal?.aborted) { throw toLintErrorObject( (abortSignal.reason as unknown) ?? new Error("Operation aborted"), "Non-Error thrown", ); } return await new Promise((_resolve, reject) => { abortSignal?.addEventListener( "abort", () => { reject( toLintErrorObject( (abortSignal.reason as unknown) ?? new Error("Operation aborted"), "Non-Error rejection", ), ); }, { once: true }, ); }); }; const makeMemoryToolAllowlistError = ( reason: string, sources = "runtime toolsAllow: memory_search, memory_get", ) => new Error( `No callable tools remain after resolving explicit tool allowlist ` + `(${sources}); ${reason}. ` + `Fix the allowlist or enable the plugin that registers the requested tool.`, ); const hasDebugLine = (needle: string) => vi .mocked(api.logger.debug) .mock.calls.some((call: unknown[]) => String(call[0]).includes(needle)); const hasWarnLine = (needle: string) => vi .mocked(api.logger.warn) .mock.calls.some((call: unknown[]) => String(call[0]).includes(needle)); const expectPrependContextResult = (result: unknown) => { expect(typeof (result as { prependContext?: unknown } | undefined)?.prependContext).toBe( "string", ); }; const requireRecord = (value: unknown, message: string): Record => { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error(message); } return value as Record; }; const requireNonEmptyString = (value: unknown, message: string): string => { if (typeof value !== "string" || value.length === 0) { throw new Error(message); } return value; }; const requirePrependContext = (result: unknown): string => requireNonEmptyString( (result as { prependContext?: unknown } | undefined)?.prependContext, "expected prependContext", ); const runRecallWithSummary = async (params: { prompt: string; summary: string; memoryText?: string; }): Promise => { runEmbeddedAgent.mockImplementationOnce(async (runParams: { sessionFile: string }) => { await writeUsableMemoryTranscript(runParams.sessionFile, params.memoryText ?? params.summary); return { payloads: [{ text: params.summary }] }; }); return requirePrependContext(await runPromptBuild({ prompt: params.prompt })); }; const expectPrependContextContains = (result: unknown, text: string) => { expect(requirePrependContext(result)).toContain(text); }; const lastEmbeddedRunParams = () => { const calls = runEmbeddedAgent.mock.calls; return requireRecord(calls[calls.length - 1]?.[0], "expected embedded run params"); }; const lastRuntimeEmbeddedRunParams = () => { const calls = runtimeRunEmbeddedAgent.mock.calls; return requireRecord(calls[calls.length - 1]?.[0], "expected runtime embedded run params"); }; const lastEmbeddedPrompt = () => requireNonEmptyString(lastEmbeddedRunParams().prompt, "expected embedded prompt"); const lastEmbeddedSessionKey = () => requireNonEmptyString(lastEmbeddedRunParams().sessionKey, "expected embedded session key"); const lastSessionStoreUpdater = () => { const calls = hoisted.updateSessionStore.mock.calls; const updater = calls[calls.length - 1]?.[1] as | ((store: Record>) => void) | undefined; if (!updater) { throw new Error("expected updateSessionStore updater"); } return updater; }; const embeddedRunConfig = () => requireRecord(lastEmbeddedRunParams().config, "expected embedded run config"); const activeMemoryConfigFrom = (config: Record) => { const plugins = requireRecord(config.plugins, "expected plugins config"); const entries = requireRecord(plugins.entries, "expected plugin entries"); const activeMemoryEntry = requireRecord( entries["active-memory"], "expected active-memory entry", ); return requireRecord(activeMemoryEntry.config, "expected active-memory config"); }; const currentActiveMemoryConfig = () => activeMemoryConfigFrom(configFile); const expectEmbeddedChannel = (messageChannel: string, messageProvider = messageChannel) => { const params = lastEmbeddedRunParams(); expect(params.messageChannel).toBe(messageChannel); expect(params.messageProvider).toBe(messageProvider); }; const firstHookRegistration = () => { const [call] = api.on.mock.calls as Array<[string, Function, Record?]>; if (!call) { throw new Error("expected before_prompt_build hook registration"); } return call; }; const runPromptBuild = ( event: Record, context: Record = {}, ) => { const defaultSession = "sessionKey" in context || "sessionId" in context ? {} : { sessionKey: "agent:main:main" }; return requireHook("before_prompt_build")( { messages: [], ...event }, { agentId: "main", trigger: "user", messageProvider: "webchat", ...defaultSession, ...context, }, ); }; const runActiveMemoryCommand = (params: Record) => { const args = typeof params.args === "string" ? params.args : "status"; return registeredCommands["active-memory"].handler({ channel: "webchat", isAuthorizedSender: true, commandBody: `/active-memory ${args}`, config: {}, requestConversationBinding: async () => ({ status: "error", message: "unsupported" }), detachConversationBinding: async () => ({ removed: false }), getCurrentConversationBinding: async () => null, ...params, }); }; const registerPluginConfig = (overrides: Record) => { api.pluginConfig = { agents: ["main"], mode: "always", ...overrides }; plugin.register(api as unknown as OpenClawPluginApi); }; const seedSession = (sessionKey: string, sessionId: string, updatedAt = 0) => { hoisted.sessionStore[sessionKey] = { sessionId, updatedAt }; }; beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-test-")); pluginStateDir = path.join(fixtureRoot, "plugin-state"); stateDir = path.join(fixtureRoot, "state"); }); beforeEach(async () => { vi.clearAllMocks(); await fs.rm(stateDir, { recursive: true, force: true }); await fs.mkdir(stateDir, { recursive: true }); // Keep the SQLite file/schema warm, but clear the plugin's only real namespace. await createPluginStateKeyedStoreForTests("active-memory", { namespace: "session-toggles", maxEntries: 10_000, env: { ...process.env, OPENCLAW_STATE_DIR: pluginStateDir }, }).clear(); runEmbeddedAgent.mockReset(); configFile = { session: { dmScope: "per-peer" }, plugins: { entries: { "active-memory": { enabled: true, config: { agents: ["main"], }, }, }, }, }; syncRuntimePluginConfig({ agents: ["main"], logging: true, }); api.config = { session: { dmScope: "per-peer" }, agents: { defaults: { model: { primary: "github-copilot/gpt-5.4-mini", }, }, }, }; for (const key of Object.keys(hoisted.sessionStore)) { delete hoisted.sessionStore[key]; } for (const key of Object.keys(hoisted.runtimeTranscriptFiles)) { delete hoisted.runtimeTranscriptFiles[key]; } hoisted.rawDeltaReads.length = 0; seedSession("agent:main:main", "s-main", 0); for (const key of Object.keys(hooks)) { delete hooks[key]; } for (const key of Object.keys(hookOptions)) { delete hookOptions[key]; } for (const key of Object.keys(registeredCommands)) { delete registeredCommands[key]; } runEmbeddedAgent.mockImplementation(async (params: { sessionFile: string }) => { await writeUsableMemoryTranscript(params.sessionFile, "lemon pepper wings with blue cheese"); return { payloads: [{ text: "- lemon pepper wings\n- blue cheese" }], }; }); hoisted.patchSessionEntry.mockImplementation( async (params: { fallbackEntry?: Record; replaceEntry?: boolean; sessionKey: string; skipMaintenance?: boolean; update: ( entry: Record, context: { existingEntry?: Record }, ) => Record | null; }) => { const existingEntry = hoisted.sessionStore[params.sessionKey]; const entry = existingEntry ?? params.fallbackEntry; if (!entry) { return null; } const patch = params.update({ ...entry }, { existingEntry }); if (!patch) { return existingEntry ?? entry; } const next = params.replaceEntry ? { ...patch } : { ...entry, ...patch }; hoisted.sessionStore[params.sessionKey] = next; return next; }, ); hoisted.cleanupSessionLifecycleArtifacts.mockImplementation( async (params: { sessionKeySegmentPrefix: string }) => { const entry = Object.entries(hoisted.sessionStore).find(([sessionKey]) => { const parsed = sessionKey.split(":").slice(2).join(":"); return parsed.startsWith(params.sessionKeySegmentPrefix); }); if (!entry) { return { archivedTranscriptArtifacts: 0, removedEntries: 0 }; } const [sessionKey, sessionEntry] = entry; const sessionId = sessionEntry.sessionId; if (typeof sessionId === "string") { const testSessionFile = hoisted.runtimeTranscriptFiles[sessionId]; if (testSessionFile) { await fs.rm(testSessionFile, { force: true }); delete hoisted.runtimeTranscriptFiles[sessionId]; } } delete hoisted.sessionStore[sessionKey]; return { archivedTranscriptArtifacts: 0, removedEntries: 1 }; }, ); testing.resetActiveRecallCacheForTests(); testing.setTimeoutPartialDataGraceMsForTests(5); plugin.register(api as unknown as OpenClawPluginApi); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); testing.resetActiveRecallCacheForTests(); }); afterAll(async () => { resetPluginStateStoreForTests(); await fs.rm(fixtureRoot, { recursive: true, force: true }); fixtureRoot = ""; pluginStateDir = ""; stateDir = ""; }); it("registers prompt-build and run-cleanup hooks", () => { const [hookName, handler, options] = firstHookRegistration(); expect(hookName).toBe("before_prompt_build"); expect(typeof handler).toBe("function"); expect(options).toEqual({ timeoutMs: 153_000 }); expect(hookOptions.before_prompt_build?.timeoutMs).toBe(153_000); expect(typeof hooks.before_model_resolve).toBe("function"); expect(typeof hooks.agent_end).toBe("function"); }); it("prewarms a cold lane-1 lookup before the first QA-channel turn budget starts", async () => { registerPluginConfig({ mode: "off" }); let cold = true; const simulatedBudgetMs = 500; const coldDelayMs = 650; const runtimePreparationMs = 500; const runId = "run-cold-qa-channel"; const triggerEntry = { path: "MEMORY.md", startLine: 1, endLine: 1, score: 1, snippet: "Prefer aisle seats.", source: "memory" as const, originClass: "agent" as const, triggers: "booking a flight", }; const warmLookup = async () => { if (cold) { await new Promise((resolve) => { setTimeout(resolve, coldDelayMs); }); cold = false; } }; const search = vi.fn(async () => { await warmLookup(); return []; }); const listTriggerCandidates = vi.fn(async () => { await warmLookup(); return [triggerEntry]; }); hoisted.getActiveMemorySearchManager.mockResolvedValue({ manager: { search, listTriggerCandidates }, } as never); const prewarmResult = await requireHook("before_model_resolve")( { prompt: "Help when booking a flight" }, { agentId: "main", runId, trigger: "user", sessionKey: "agent:main:qa-channel:direct:owner", messageProvider: "qa-channel", channelId: "owner", }, ); expect(prewarmResult).toBeUndefined(); expect(coldDelayMs).toBeGreaterThan(simulatedBudgetMs); await new Promise((resolve) => { setTimeout(resolve, runtimePreparationMs); }); const startedAt = performance.now(); const result = await runPromptBuild( { prompt: "Help when booking a flight" }, { sessionKey: "agent:main:qa-channel:direct:owner", messageProvider: "qa-channel", channelId: "owner", runId, }, ); const firstTurnMs = performance.now() - startedAt; expectPrependContextContains(result, "Prefer aisle seats."); expect(cold).toBe(false); expect(firstTurnMs).toBeLessThan(simulatedBudgetMs); expect(search).toHaveBeenCalledTimes(1); expect(listTriggerCandidates).toHaveBeenCalledTimes(1); expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("does not prewarm a session disabled with /active-memory off", async () => { const sessionKey = "agent:main:qa-channel:direct:paused"; seedSession(sessionKey, "s-paused"); await runActiveMemoryCommand({ sessionKey, args: "off" }); await requireHook("before_model_resolve")( { prompt: "Help when booking a flight" }, { agentId: "main", runId: "run-paused-qa-channel", trigger: "user", sessionKey, messageProvider: "qa-channel", channelId: "paused", }, ); expect(hoisted.getActiveMemorySearchManager).not.toHaveBeenCalled(); }); it("does not synthesize a main agent when every configured agent opts out", () => { expect( hasRememberAcrossConversationsAgent({ agents: { list: [ { id: "personal", memory: { search: { rememberAcrossConversations: false } } }, { id: "support", memory: { search: { rememberAcrossConversations: false } } }, ], }, }), ).toBe(false); }); it("keeps the outer hook timeout at the live-config ceiling", () => { registerPluginConfig({ timeoutMs: 90_000 }); expect(hookOptions.before_prompt_build?.timeoutMs).toBe(153_000); }); it("covers the maximum recall and setup-grace budgets", () => { registerPluginConfig({ timeoutMs: 90_000, setupGraceTimeoutMs: 30_000 }); expect(hookOptions.before_prompt_build?.timeoutMs).toBe(153_000); }); it("runs recall without recording shared auth-profile failures", async () => { await runPromptBuild({ prompt: "what wings should i order?" }); expect(lastEmbeddedRunParams().authProfileFailurePolicy).toBe("local"); // Subscription-only claude-cli setups route recall through the CLI // backend instead of the direct-API passthrough. expect(lastEmbeddedRunParams().cliBackendDispatch).toBe("subscription-auth"); }); it("runs recall on a dedicated active-memory lane", async () => { await runPromptBuild({ prompt: "what wings should i order?" }); expect(lastEmbeddedRunParams().lane).toBe("active-memory"); }); it("creates and removes the exact SQLite recall session around the embedded run", async () => { let persistedEntryAtRun: Record | undefined; runEmbeddedAgent.mockImplementationOnce(async (params: Record) => { const sessionKey = requireNonEmptyString(params.sessionKey, "expected embedded session key"); persistedEntryAtRun = structuredClone(hoisted.sessionStore[sessionKey]); const sessionFile = requireNonEmptyString( params.sessionFile, "expected test transcript artifact", ); await writeUsableMemoryTranscript(sessionFile, "lemon pepper wings"); return { payloads: [{ text: "- lemon pepper wings" }] }; }); const result = await runPromptBuild({ prompt: "what wings should i order?" }); const runtimeParams = lastRuntimeEmbeddedRunParams(); const sessionId = requireNonEmptyString(runtimeParams.sessionId, "expected runtime session id"); const sessionKey = requireNonEmptyString( runtimeParams.sessionKey, "expected runtime session key", ); const runtimeSessionFile = requireNonEmptyString( runtimeParams.sessionFile, "expected runtime session file", ); expect(parseSqliteSessionFileMarker(runtimeSessionFile)).toEqual({ agentId: "main", sessionId, storePath: path.join(stateDir, "sessions.json"), }); expect(persistedEntryAtRun).toMatchObject({ pluginOwnerId: "active-memory", sessionId, sessionFile: runtimeSessionFile, }); expect(hoisted.patchSessionEntry).toHaveBeenCalledWith( expect.objectContaining({ fallbackEntry: expect.objectContaining({ pluginOwnerId: "active-memory", sessionId, sessionFile: runtimeSessionFile, }), replaceEntry: true, sessionKey, skipMaintenance: true, storePath: path.join(stateDir, "sessions.json"), }), ); expect(hoisted.cleanupSessionLifecycleArtifacts).toHaveBeenCalledWith({ agentId: "main", archiveRemovedEntryTranscripts: false, orphanTranscriptMinAgeMs: 0, sessionKeySegmentPrefix: parseAgentSessionKey(sessionKey)?.rest, storePath: path.join(stateDir, "sessions.json"), transcriptContentMarker: `"runId":"${sessionId}"`, }); expect(hoisted.sessionStore[sessionKey]).toBeUndefined(); expectPrependContextContains(result, "lemon pepper wings"); }); it("uses a unique SQLite session key for repeated identical recalls", async () => { const hookParams = { agentId: "main", trigger: "user", sessionKey: "agent:main:main", messageProvider: "webchat", }; const event = { prompt: "what wings should i order?", messages: [] }; await runPromptBuild(event, hookParams); const firstSessionKey = requireNonEmptyString( lastRuntimeEmbeddedRunParams().sessionKey, "expected first runtime session key", ); testing.resetActiveRecallCacheForTests(); await runPromptBuild(event, hookParams); const secondSessionKey = requireNonEmptyString( lastRuntimeEmbeddedRunParams().sessionKey, "expected second runtime session key", ); expect(secondSessionKey).not.toBe(firstSessionKey); }); it("shares recall results across changed-prompt retries in one run", async () => { const context = { runId: "run-changed-prompt-retry", sessionKey: "agent:main:changed-prompt-retry", }; const first = await runPromptBuild({ prompt: "what wings should i order?" }, context); const second = await runPromptBuild( { prompt: "actually, what did I order last time?" }, context, ); expectPrependContextContains(first, "lemon pepper wings"); expectPrependContextContains(second, "lemon pepper wings"); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); }); it("joins concurrent recall attempts in one run", async () => { let releaseRecall: () => void = () => { throw new Error("recall gate was not initialized"); }; const recallGate = new Promise((resolve) => { releaseRecall = resolve; }); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await recallGate; await writeUsableMemoryTranscript(params.sessionFile, "lemon pepper wings"); return { payloads: [{ text: "- lemon pepper wings" }] }; }); const context = { runId: "run-concurrent-attempts", sessionKey: "agent:main:concurrent-attempts", }; const first = runPromptBuild({ prompt: "what wings should i order?" }, context); await vi.waitFor(() => { expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); }); const second = runPromptBuild({ prompt: "what wings did I order last time?" }, context); await new Promise((resolve) => { setImmediate(resolve); }); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); releaseRecall(); const [firstResult, secondResult] = await Promise.all([first, second]); expectPrependContextContains(firstResult, "lemon pepper wings"); expectPrependContextContains(secondResult, "lemon pepper wings"); }); it("waits for timeout cleanup before replacing a recall in the same run", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 100, logging: true }); let releaseCleanup: () => void = () => { throw new Error("cleanup gate was not initialized"); }; const cleanupGate = new Promise((resolve) => { releaseCleanup = resolve; }); hoisted.closeActiveMemorySearchManager.mockImplementationOnce(async () => { await cleanupGate; }); runEmbeddedAgent.mockImplementationOnce( async (params: { abortSignal?: AbortSignal }) => await waitForAbort(params.abortSignal), ); const context = { runId: "run-timeout-retry", sessionKey: "agent:main:timeout-retry", }; await expect( runPromptBuild({ prompt: "what wings should i order before timeout?" }, context), ).resolves.toBeUndefined(); await vi.waitFor(() => { expect(hoisted.closeActiveMemorySearchManager).toHaveBeenCalledTimes(1); }); const retry = runPromptBuild({ prompt: "what wings should i order after timeout?" }, context); await new Promise((resolve) => { setImmediate(resolve); }); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); releaseCleanup(); await expect(retry).resolves.toEqual( expect.objectContaining({ prependContext: expect.stringContaining("lemon pepper wings") }), ); expect(runEmbeddedAgent).toHaveBeenCalledTimes(2); }); it("evicts a rejected replacement after timeout cleanup settles", async () => { let releaseCleanup: () => void = () => { throw new Error("cleanup gate was not initialized"); }; const cleanupGate = new Promise((resolve) => { releaseCleanup = resolve; }); const initialResult = { status: "timeout" as const, elapsedMs: 1, summary: null }; await expect( resolveActiveRecallForRun("run-rejected-replacement", async (onTimeoutCleanup) => { onTimeoutCleanup(cleanupGate); return initialResult; }), ).resolves.toEqual(initialResult); let rejectedReplacementStarts = 0; const rejectedReplacement = resolveActiveRecallForRun("run-rejected-replacement", async () => { rejectedReplacementStarts++; throw new Error("retry deadline expired"); }); releaseCleanup(); await expect(rejectedReplacement).rejects.toThrow("retry deadline expired"); const freshResult = { status: "ok" as const, elapsedMs: 2, rawReply: "recovered", summary: "recovered", }; let freshStarts = 0; await expect( resolveActiveRecallForRun("run-rejected-replacement", async () => { freshStarts++; return freshResult; }), ).resolves.toEqual(freshResult); expect({ freshStarts, rejectedReplacementStarts }).toEqual({ freshStarts: 1, rejectedReplacementStarts: 1, }); }); it("deduplicates cache-disabled private recall until the run ends", async () => { setMemorySlot("memory-core"); configFile = { ...configFile, agents: { defaults: { model: { primary: "github-copilot/gpt-5.4-mini" }, }, list: [ { id: "main", memory: { search: { rememberAcrossConversations: true } }, }, ], }, }; const context = { runId: "run-private-recall", sessionKey: "agent:main:telegram:direct:owner", messageProvider: "telegram", channelId: "owner", }; seedSession(context.sessionKey, "s-private-recall", 0); await runPromptBuild({ prompt: "what wings should i order?" }, context); await runPromptBuild({ prompt: "what wings should i order?" }, context); expect(lastEmbeddedRunParams().conversationRecall).toEqual({ anchorSessionKey: context.sessionKey, scope: "same-agent-private", corpus: "configured", }); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); await requireHook("agent_end")({ runId: context.runId, messages: [], success: true }, context); await runPromptBuild({ prompt: "what wings should i order?" }, context); expect(runEmbeddedAgent).toHaveBeenCalledTimes(2); }); it("retries transient SQLite recall cleanup failures", async () => { hoisted.cleanupSessionLifecycleArtifacts.mockRejectedValueOnce( new Error("session store is busy"), ); const result = await runPromptBuild({ prompt: "what wings should i order? retry cleanup" }); expectPrependContextContains(result, "lemon pepper wings"); expect(hoisted.cleanupSessionLifecycleArtifacts).toHaveBeenCalledTimes(2); expect(api.logger.warn).not.toHaveBeenCalledWith( expect.stringContaining("failed to clean up recall session"), ); }); it("cleans the transient workspace when SQLite recall cleanup retries are exhausted", async () => { const rmSpy = vi.spyOn(fs, "rm"); hoisted.cleanupSessionLifecycleArtifacts.mockRejectedValue( new Error("session store remains busy"), ); const result = await runPromptBuild({ prompt: "what wings should i order? cleanup failure" }); expect(result).toBeUndefined(); expect(hoisted.cleanupSessionLifecycleArtifacts).toHaveBeenCalledTimes(3); expect(rmSpy).toHaveBeenCalledWith(expect.stringMatching(/openclaw-active-memory-.*/), { recursive: true, force: true, }); expect(api.logger.warn).toHaveBeenCalledWith( expect.stringContaining("failed to clean up recall session"), ); }); it("runs product recall for an opted-in direct session without an Active Memory agent entry", async () => { syncRuntimePluginConfig({ agents: [], logging: true }); configFile = { ...configFile, agents: { list: [ { id: "personal", workspace: "/tmp/live-personal-workspace", agentDir: "/tmp/live-personal-agent", model: { primary: "openai/gpt-5.5" }, memory: { search: { rememberAcrossConversations: true } }, }, ], }, }; const sessionKey = "agent:personal:telegram:direct:owner"; hoisted.sessionStore[sessionKey] = { sessionId: "s-personal", updatedAt: 0 }; await runPromptBuild( { prompt: "what do I usually order?" }, { agentId: "personal", sessionKey, messageProvider: "telegram", channelId: "owner", }, ); expect(lastEmbeddedRunParams().conversationRecall).toEqual({ anchorSessionKey: sessionKey, scope: "same-agent-private", corpus: "sessions", }); expect(lastEmbeddedRunParams().toolsAllow).toEqual(["memory_search"]); expect(lastEmbeddedRunParams()).toMatchObject({ workspaceDir: "/tmp/live-personal-workspace", agentDir: "/tmp/live-personal-agent", provider: "openai", model: "gpt-5.5", }); expect(embeddedRunConfig()).toMatchObject({ agents: { list: [ { id: "personal", workspace: "/tmp/live-personal-workspace", agentDir: "/tmp/live-personal-agent", model: { primary: "openai/gpt-5.5" }, memory: { search: { rememberAcrossConversations: true } }, }, ], }, }); }); it("keeps product recall available when Lossless Claw owns the context-engine slot", async () => { syncRuntimePluginConfig({ agents: [], logging: true }); configFile = { ...configFile, agents: { list: [ { id: "personal", model: { primary: "github-copilot/gpt-5.4-mini" }, memory: { search: { rememberAcrossConversations: true } }, }, ], }, plugins: { ...(configFile.plugins as Record), slots: { contextEngine: "lossless-claw" }, }, }; const sessionKey = "agent:personal:telegram:direct:owner"; hoisted.sessionStore[sessionKey] = { sessionId: "s-personal", updatedAt: 0 }; await runPromptBuild( { prompt: "what do I usually order?" }, { agentId: "personal", sessionKey, messageProvider: "telegram", channelId: "owner", }, ); expect(lastEmbeddedRunParams().conversationRecall).toEqual({ anchorSessionKey: sessionKey, scope: "same-agent-private", corpus: "sessions", }); expect(hasWarnLine("does not support protected private transcript recall")).toBe(false); }); it("matches case-preserved conversation ids against lowercased deny lists", async () => { registerPluginConfig({ allowedChatTypes: ["direct", "group"], deniedChatIds: ["AbCdEfGh=="] }); const result = await runPromptBuild( { prompt: "hi" }, { sessionKey: "agent:main:signal:group:AbCdEfGh==", messageProvider: "signal", channelId: "signal", }, ); expect(runEmbeddedAgent).not.toHaveBeenCalled(); expect(result).toBeUndefined(); }); it.each([ { name: "a denylisted main-scope DM", sessionKey: "agent:personal:main", messageProvider: "telegram", channelId: "owner", pluginConfig: { agents: [], logging: true, deniedChatIds: ["owner"] }, memorySlot: "memory-core", warn: undefined, }, { name: "a group destination", sessionKey: "agent:personal:telegram:group:family", messageProvider: "telegram", channelId: "family", pluginConfig: { agents: [], logging: true }, memorySlot: "memory-core", warn: undefined, }, { name: "a headless explicit destination", sessionKey: "agent:personal:explicit:headless-run", messageProvider: undefined, channelId: undefined, pluginConfig: { agents: [], logging: true }, memorySlot: "memory-core", warn: undefined, }, { name: "an unsupported memory provider", sessionKey: "agent:personal:telegram:direct:owner", messageProvider: "telegram", channelId: "owner", pluginConfig: { agents: [], toolsAllow: ["memory_search"], logging: true }, memorySlot: "memory-lancedb", warn: "does not support protected private transcript recall", }, { name: "a missing memory_search tool", sessionKey: "agent:personal:telegram:direct:owner", messageProvider: "telegram", channelId: "owner", pluginConfig: { agents: [], toolsAllow: ["memory_get"], logging: true }, memorySlot: "memory-core", warn: "memory_search is unavailable", }, ])("fails closed without product recall for $name", async (testCase) => { syncRuntimePluginConfig(testCase.pluginConfig); setMemorySlot(testCase.memorySlot); configFile = { ...configFile, agents: { list: [{ id: "personal", memory: { search: { rememberAcrossConversations: true } } }], }, }; hoisted.sessionStore[testCase.sessionKey] = { sessionId: "s-personal", updatedAt: 0 }; const result = await runPromptBuild( { prompt: "what do I usually order?" }, { agentId: "personal", sessionKey: testCase.sessionKey, messageProvider: testCase.messageProvider, channelId: testCase.channelId, }, ); expect(result).toBeUndefined(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); if (testCase.warn) { expect(hasWarnLine(testCase.warn)).toBe(true); } }); it("runs product recall by default for a personal install without Active Memory config", async () => { configFile = { agents: { list: [ { id: "personal", model: { primary: "github-copilot/gpt-5.4-mini" }, }, ], }, plugins: { entries: {} }, }; const sessionKey = "agent:personal:telegram:direct:owner"; hoisted.sessionStore[sessionKey] = { sessionId: "s-personal", updatedAt: 0 }; await runPromptBuild( { prompt: "what do I usually order?" }, { agentId: "personal", sessionKey, messageProvider: "telegram", channelId: "owner", }, ); expect(lastEmbeddedRunParams().conversationRecall).toEqual({ anchorSessionKey: sessionKey, scope: "same-agent-private", corpus: "sessions", }); }); it("keeps advanced recall corpora separate from product recall", async () => { syncRuntimePluginConfig({ agents: ["personal"], allowedChatTypes: ["direct", "group"] }); configFile = { ...configFile, agents: { list: [ { id: "personal", model: { primary: "github-copilot/gpt-5.4-mini" }, memory: { search: { rememberAcrossConversations: true } }, }, ], }, }; const directSessionKey = "agent:personal:telegram:direct:owner"; hoisted.sessionStore[directSessionKey] = { sessionId: "s-personal", updatedAt: 0 }; await runPromptBuild( { prompt: "what do I usually order?" }, { agentId: "personal", sessionKey: directSessionKey, messageProvider: "telegram", channelId: "owner", }, ); expect(lastEmbeddedRunParams().conversationRecall).toEqual({ anchorSessionKey: directSessionKey, scope: "same-agent-private", corpus: "configured", }); await runPromptBuild( { prompt: "what context helps here?" }, { agentId: "personal", sessionKey: "agent:personal:telegram:group:family", messageProvider: "telegram", channelId: "family", }, ); expect(lastEmbeddedRunParams().conversationRecall).toBeUndefined(); }); it("lets a remember-only agent pause recall for one session", async () => { syncRuntimePluginConfig({ agents: [], logging: true }); configFile = { ...configFile, agents: { list: [{ id: "personal", memory: { search: { rememberAcrossConversations: true } } }], }, }; const sessionKey = "agent:personal:telegram:direct:owner"; hoisted.sessionStore[sessionKey] = { sessionId: "s-personal", updatedAt: 0 }; const offResult = await runActiveMemoryCommand({ channel: "telegram", sessionKey, args: "off", config: configFile, }); await runPromptBuild( { prompt: "what do I usually order?" }, { agentId: "personal", sessionKey, messageProvider: "telegram", channelId: "owner", }, ); expect(offResult.text).toBe("Active Memory: off for this session."); expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("registers a session-scoped active-memory toggle command", async () => { const command = registeredCommands["active-memory"]; const sessionKey = "agent:main:active-memory-toggle"; seedSession(sessionKey, "s-active-memory-toggle", 0); expect(command.name).toBe("active-memory"); expect(command.acceptsArgs).toBe(true); expect(command.exposeSenderIsOwner).toBe(true); const offResult = await runActiveMemoryCommand({ sessionKey, args: "off", }); expect(offResult.text).toContain("off for this session"); const statusResult = await runActiveMemoryCommand({ sessionKey, args: "status", }); expect(statusResult.text).toBe("Active Memory: off for this session."); const disabledResult = await runPromptBuild( { prompt: "what wings should i order? active memory toggle" }, { sessionKey, }, ); expect(disabledResult).toBeUndefined(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); const onResult = await runActiveMemoryCommand({ sessionKey, args: "on", }); expect(onResult.text).toContain("on for this session"); await runPromptBuild( { prompt: "what wings should i order? active memory toggle" }, { sessionKey, }, ); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); }); it("reports session status off when the current agent is outside the active-memory allowlist (#78986)", async () => { registerPluginConfig({ agents: ["sandbox"], logging: true, }); const statusResult = await runActiveMemoryCommand({ sessionKey: "agent:main:main", args: "status", }); expect(statusResult.text).toBe("Active Memory: off for this session."); }); it("supports an explicit global active-memory config toggle", async () => { const offResult = await runActiveMemoryCommand({ senderIsOwner: true, args: "off --global", }); expect(offResult.text).toBe("Active Memory: off globally."); expect(api.runtime.config.mutateConfigFile).toHaveBeenCalledTimes(1); expect( requireRecord( requireRecord(requireRecord(configFile.plugins, "plugins").entries, "entries")[ "active-memory" ], "active-memory entry", ).enabled, ).toBe(true); expect(currentActiveMemoryConfig().enabled).toBe(false); expect(currentActiveMemoryConfig().agents).toEqual(["main"]); const statusOffResult = await runActiveMemoryCommand({ args: "status --global", }); expect(statusOffResult.text).toBe("Active Memory: off globally."); await runPromptBuild( { prompt: "what wings should i order while global active memory is off?" }, { sessionKey: "agent:main:global-toggle", }, ); expect(runEmbeddedAgent).not.toHaveBeenCalled(); const onResult = await runActiveMemoryCommand({ senderIsOwner: true, args: "on --global", }); expect(onResult.text).toBe("Active Memory: on globally."); expect( requireRecord( requireRecord(requireRecord(configFile.plugins, "plugins").entries, "entries")[ "active-memory" ], "active-memory entry", ).enabled, ).toBe(true); expect(currentActiveMemoryConfig().enabled).toBe(true); expect(currentActiveMemoryConfig().agents).toEqual(["main"]); await runPromptBuild( { prompt: "what wings should i order after global active memory is back on?" }, { sessionKey: "agent:main:global-toggle", }, ); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); }); it("blocks external non-owner callers from changing global active-memory config", async () => { for (const args of ["off --global", "on --global"]) { const result = await runActiveMemoryCommand({ channel: "telegram", senderIsOwner: false, args, }); expect(result.text).toContain( "global enable/disable changes require owner or operator.admin", ); } expect(api.runtime.config.mutateConfigFile).not.toHaveBeenCalled(); }); it("blocks gateway callers without admin scope from changing global active-memory config", async () => { for (const { args, gatewayClientScopes } of [ { args: "off --global", gatewayClientScopes: ["operator.write"] }, { args: "on --global", gatewayClientScopes: ["operator.write"] }, { args: "disable --global", gatewayClientScopes: ["operator.write"] }, { args: "enable --global", gatewayClientScopes: ["operator.write"] }, { args: "disabled --global", gatewayClientScopes: ["operator.write"] }, { args: "enabled --global", gatewayClientScopes: ["operator.write"] }, { args: "off --global", gatewayClientScopes: [] }, ]) { const result = await runActiveMemoryCommand({ channel: "gateway", gatewayClientScopes, args, }); expect(result.text).toContain( "global enable/disable changes require owner or operator.admin", ); } expect(api.runtime.config.mutateConfigFile).not.toHaveBeenCalled(); }); it("allows admin-scoped gateway callers to change global active-memory config", async () => { const result = await runActiveMemoryCommand({ channel: "gateway", gatewayClientScopes: ["operator.admin"], args: "off --global", }); expect(result.text).toBe("Active Memory: off globally."); expect(api.runtime.config.mutateConfigFile).toHaveBeenCalledTimes(1); expect( requireRecord( requireRecord(requireRecord(configFile.plugins, "plugins").entries, "entries")[ "active-memory" ], "active-memory entry", ).enabled, ).toBe(true); expect(currentActiveMemoryConfig().enabled).toBe(false); expect(currentActiveMemoryConfig().agents).toEqual(["main"]); }); it("keeps write-scoped gateway callers on non-global-write active-memory paths", async () => { const sessionKey = "agent:main:write-scoped-active-memory"; seedSession(sessionKey, "s-write-scoped-active-memory", 0); const globalStatusResult = await runActiveMemoryCommand({ channel: "gateway", gatewayClientScopes: ["operator.write"], args: "status --global", }); expect(globalStatusResult.text).toBe("Active Memory: on globally."); expect(api.runtime.config.replaceConfigFile).not.toHaveBeenCalled(); const sessionOffResult = await runActiveMemoryCommand({ channel: "gateway", gatewayClientScopes: ["operator.write"], sessionKey, args: "off", }); expect(sessionOffResult.text).toBe("Active Memory: off for this session."); expect(api.runtime.config.replaceConfigFile).not.toHaveBeenCalled(); }); it("uses live runtime config for before_prompt_build enablement", async () => { configFile = { plugins: { entries: { "active-memory": { enabled: true, config: { enabled: false, agents: ["main"], }, }, }, }, }; const result = await runPromptBuild( { prompt: "what wings should i order after a live config disable?" }, { sessionKey: "agent:main:live-config-disable", }, ); expect(result).toBeUndefined(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it.each(["你还记得我们上次讨论的数据库配置吗?", "你还记得我们上周决定明天部署的方案吗?"])( "escalates only retrospective Chinese %j when recall mode is unset", async (prompt) => { registerPluginConfig({ mode: undefined }); expect(currentActiveMemoryConfig().mode).toBeUndefined(); const context = { sessionKey: "agent:main:telegram:direct:owner", messageProvider: "telegram", channelId: "owner", }; const ordinary = await runPromptBuild({ prompt: "部署之前先整理聊天记录" }, context); expect(ordinary).toBeUndefined(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); const future = await runPromptBuild({ prompt: "你记得明天发送报告吗?" }, context); expect(future).toBeUndefined(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); const recall = await runPromptBuild({ prompt }, context); expect(runEmbeddedAgent).toHaveBeenCalledOnce(); expectPrependContextContains(recall, "lemon pepper wings"); expectEmbeddedChannel("telegram"); }, ); it("fails closed when the live active-memory plugin entry is removed", async () => { configFile = { plugins: { entries: {}, }, }; const result = await runPromptBuild( { prompt: "what wings should i order after active memory is removed?" }, { sessionKey: "agent:main:live-config-removed", }, ); expect(result).toBeUndefined(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("does not run for agents that are not explicitly targeted", async () => { const result = await runPromptBuild( { prompt: "what wings should i order?" }, { agentId: "support", sessionKey: "agent:support:main", }, ); expect(result).toBeUndefined(); expect(hoisted.getActiveMemorySearchManager).not.toHaveBeenCalled(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it.each([ { label: "a missing reserved Codex harness key", sessionKey: "agent:main:harness:codex:supervision:thread-1", entry: undefined, }, { label: "a model-locked session", sessionKey: "agent:main:locked-codex-session", entry: { sessionId: "codex-2", updatedAt: 1, modelSelectionLocked: true, agentHarnessId: "codex", }, }, ])( "skips recall before state or model side effects for $label", async ({ sessionKey, entry }) => { if (entry) { hoisted.sessionStore[sessionKey] = entry; } const openKeyedStore = vi.spyOn(api.runtime.state, "openKeyedStore"); const result = await runPromptBuild( { prompt: "continue this native Codex task" }, { sessionKey, }, ); expect(result).toBeUndefined(); expect(openKeyedStore).not.toHaveBeenCalled(); expect(hoisted.updateSessionStore).not.toHaveBeenCalled(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); }, ); it("does not rewrite session state for skipped turns with no active-memory entry to clear", async () => { const result = await runPromptBuild( { prompt: "what wings should i order?" }, { agentId: "support", sessionKey: "agent:support:main", }, ); expect(result).toBeUndefined(); expect(hoisted.updateSessionStore).not.toHaveBeenCalled(); }); it.each([ { name: "does not run for non-interactive contexts", context: { trigger: "heartbeat" }, expected: "skip", }, { name: "does not run for dreaming-narrative cron session keys", context: { sessionKey: "agent:main:dreaming-narrative-light-abc123" }, expected: "skip", }, { name: "does not run when a session id resolves to a dreaming-narrative cron session key", context: { sessionId: "dreaming-session" }, sessionEntry: { sessionKey: "agent:main:dreaming-narrative-light-abc123", entry: { sessionId: "dreaming-session", updatedAt: 1 }, }, expected: "skip", }, { name: "allows non-canonical session keys that merely contain the dreaming-narrative substring", context: { sessionKey: "agent:main:webchat:dreaming-narrative-room" }, expected: "defined", }, { name: "allows real webchat session keys whose peer id starts with a phased dreaming-narrative prefix", context: { sessionKey: "agent:main:webchat:dreaming-narrative-light-room" }, expected: "defined", }, { name: "defaults to direct-style sessions only", prompt: "what wings should we order?", context: { sessionKey: "agent:main:telegram:group:-100123", messageProvider: "telegram", channelId: "telegram", }, expected: "skip", }, { name: "treats non-webchat main sessions as direct chats under the default dmScope", context: { messageProvider: "telegram", channelId: "telegram" }, expected: "untrusted", }, { name: "treats non-default main session keys as direct chats", apiConfig: { agents: { defaults: { model: { primary: "github-copilot/gpt-5.4-mini" } } }, session: { mainKey: "home" }, }, context: { sessionKey: "agent:main:home", messageProvider: "telegram", channelId: "telegram", }, expected: "untrusted", }, { name: "treats topic-threaded Telegram main session keys as direct chats", context: { sessionKey: "agent:main:main:thread:488228716:531403", messageProvider: "telegram", channelId: "telegram", }, expected: "untrusted", }, { name: "does not treat unknown topic-threaded session keys as direct chats", context: { sessionKey: "agent:main:future:thread:488228716:531403", messageProvider: "telegram", channelId: "telegram", }, expected: "skip", }, { name: "runs for group sessions when group chat types are explicitly allowed", prompt: "what wings should we order?", pluginConfig: { allowedChatTypes: ["direct", "group"] }, context: { sessionKey: "agent:main:telegram:group:-100123", messageProvider: "telegram", channelId: "telegram", }, expected: "untrusted", }, { name: "uses messageProvider not topic channelId for embedded recall in Telegram forum topics (#76704)", prompt: "what wings should we order?", pluginConfig: { allowedChatTypes: ["direct", "group"] }, context: { sessionKey: "agent:main:telegram:group:-100123:topic:77", messageProvider: "telegram", channelId: "-100123:topic:77", }, expected: "untrusted", expectedChannel: "telegram", }, { name: "uses messageProvider not raw Telegram direct channelId for embedded recall (#82177)", context: { messageProvider: "telegram", channelId: "12345" }, expected: "untrusted", expectedChannel: "telegram", }, { name: "uses messageProvider not Google Chat space id for embedded recall (#78918)", prompt: "what did we decide?", pluginConfig: { allowedChatTypes: ["direct"] }, context: { sessionKey: "agent:main:googlechat:default:direct:spaces/khfx4yaaaae", messageProvider: "googlechat", channelId: "spaces/khfx4yaaaae", }, expected: "untrusted", expectedChannel: "googlechat", }, { name: "runs for explicit sessions when explicit chat types are explicitly allowed", prompt: "what should i work on next?", pluginConfig: { allowedChatTypes: ["explicit"] }, context: { sessionKey: "agent:main:explicit:portal-123", channelId: "webchat" }, expected: "active-memory", }, { name: "keeps explicit session classification when the opaque session id contains chat-type tokens", prompt: "what should i work on next?", pluginConfig: { allowedChatTypes: ["explicit"] }, context: { sessionKey: "agent:main:explicit:portal-123:group:shadow", channelId: "webchat", }, expected: "active-memory", }, { name: "skips group sessions whose conversation id is not in allowedChatIds", pluginConfig: { allowedChatTypes: ["direct", "group"], allowedChatIds: ["oc_allowed_group"], }, context: { sessionKey: "agent:main:feishu:group:oc_blocked_group", messageProvider: "feishu", channelId: "feishu", }, expected: "skip", }, { name: "runs for group sessions whose conversation id is in allowedChatIds", pluginConfig: { allowedChatTypes: ["direct", "group"], allowedChatIds: ["oc_allowed_group", "OC_OTHER"], }, context: { sessionKey: "agent:main:feishu:group:oc_allowed_group", messageProvider: "feishu", channelId: "feishu", }, expected: "untrusted", }, { name: "treats allowedChatIds matching as case-insensitive", pluginConfig: { allowedChatTypes: ["group"], allowedChatIds: ["OC_MIXED_Case"] }, context: { sessionKey: "agent:main:feishu:group:oc_mixed_case", messageProvider: "feishu", channelId: "feishu", }, expected: "prepend-context", }, { name: "skips sessions whose conversation id is in deniedChatIds even when chat type is allowed", pluginConfig: { allowedChatTypes: ["direct", "group"], deniedChatIds: ["oc_blocked_group"], }, context: { sessionKey: "agent:main:feishu:group:oc_blocked_group", messageProvider: "feishu", channelId: "feishu", }, expected: "skip", }, { name: "skips sessions whose session key has no conversation id when allowedChatIds is non-empty", pluginConfig: { allowedChatTypes: ["direct"], allowedChatIds: ["oc_some_group"] }, expected: "skip", }, { name: "skips direct-chat sessions whose conversation id is not in allowedChatIds", pluginConfig: { allowedChatTypes: ["direct", "group"], allowedChatIds: ["oc_allowed_group"], }, context: { sessionKey: "agent:main:feishu:direct:ou_some_direct_user", messageProvider: "feishu", channelId: "feishu", }, expected: "skip", }, { name: "runs for direct-chat sessions whose conversation id is explicitly in allowedChatIds", pluginConfig: { allowedChatTypes: ["direct", "group"], allowedChatIds: ["oc_allowed_group", "ou_allowed_direct_user"], }, context: { sessionKey: "agent:main:feishu:direct:ou_allowed_direct_user", messageProvider: "feishu", channelId: "feishu", }, expected: "prepend-context", }, { name: "matches per-peer direct session keys (agent::direct:)", pluginConfig: { allowedChatTypes: ["direct"], allowedChatIds: ["ou_per_peer_user"] }, context: { sessionKey: "agent:main:direct:ou_per_peer_user", messageProvider: "feishu", channelId: "feishu", }, expected: "prepend-context", }, { name: "matches per-account-channel-peer direct session keys (agent::::direct:)", pluginConfig: { allowedChatTypes: ["direct"], allowedChatIds: ["ou_per_account_user"], }, context: { sessionKey: "agent:main:feishu:acct123:direct:ou_per_account_user", messageProvider: "feishu", channelId: "feishu", }, expected: "prepend-context", }, { name: "strips :thread: suffix before matching allowedChatIds (group)", pluginConfig: { allowedChatTypes: ["group"], allowedChatIds: ["oc_threaded_group"] }, context: { sessionKey: "agent:main:feishu:group:oc_threaded_group:thread:topic42", messageProvider: "feishu", channelId: "feishu", }, expected: "prepend-context", }, { name: "strips :thread: suffix before matching deniedChatIds (direct)", pluginConfig: { allowedChatTypes: ["direct"], deniedChatIds: ["ou_threaded_blocked_user"], }, context: { sessionKey: "agent:main:feishu:direct:ou_threaded_blocked_user:thread:topic7", messageProvider: "feishu", channelId: "feishu", }, expected: "skip", }, ])( "$name", async ({ apiConfig: testApiConfig, context, expected, expectedChannel, pluginConfig: testPluginConfig, prompt = "what wings should i order?", sessionEntry, }) => { if (testApiConfig) { api.config = testApiConfig; } if (testPluginConfig) { registerPluginConfig({ agents: ["main"], ...testPluginConfig }); } if (sessionEntry) { hoisted.sessionStore[sessionEntry.sessionKey] = sessionEntry.entry; } const result = await runPromptBuild({ prompt }, context); if (expected === "skip") { expect(result).toBeUndefined(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); return; } expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); if (expected === "defined") { expect(result).not.toBeUndefined(); } else if (expected === "prepend-context") { expectPrependContextResult(result); } else { expectPrependContextContains( result, expected === "active-memory" ? "" : "Context:", ); } if (expectedChannel) { expectEmbeddedChannel(expectedChannel); } }, ); it("injects system context on a successful recall hit", async () => { const result = await runPromptBuild({ prompt: "what wings should i order?", messages: [ { role: "user", content: "i want something greasy tonight" }, { role: "assistant", content: "let's narrow it down" }, ], }); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); const prependContext = requirePrependContext(result); expect(prependContext).toContain("Context:"); expect(prependContext).toContain("lemon pepper wings"); const params = lastEmbeddedRunParams(); expect(params.provider).toBe("github-copilot"); expect(params.model).toBe("gpt-5.4-mini"); expect(params.messageProvider).toBe("webchat"); expect(params.sessionKey).toMatch(/^agent:main:main:active-memory:[a-f0-9]{12}$/); expect(activeMemoryConfigFrom(embeddedRunConfig()).qmd).toEqual({ searchMode: "search" }); expect(params.cleanupBundleMcpOnRunEnd).toBe(true); }); it("keeps deterministic trigger recall out of group destinations", async () => { registerPluginConfig({ allowedChatTypes: ["direct", "group"] }); await runPromptBuild( { prompt: "what did we decide?" }, { sessionKey: "agent:main:telegram:group:-100123", messageProvider: "telegram", channelId: "telegram", }, ); expect(hoisted.getActiveMemorySearchManager).not.toHaveBeenCalled(); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); }); it("logs deterministic trigger injections when invocation logging is enabled", async () => { hoisted.getActiveMemorySearchManager.mockResolvedValueOnce({ manager: { search: vi.fn(async () => []), listTriggerCandidates: vi.fn(async () => [ { path: "MEMORY.md", startLine: 1, endLine: 1, score: 1, snippet: "Prefer aisle seats.", source: "memory" as const, originClass: "agent", triggers: "booking a flight", }, ]), }, } as never); await runPromptBuild( { prompt: "Help when booking a flight" }, { sessionKey: "agent:main:telegram:direct:owner", messageProvider: "telegram", channelId: "owner", }, ); expect( vi .mocked(api.logger.info) .mock.calls.some( (call: unknown[]) => String(call[0]) === "active-memory: lane-1 injected 1 trigger-matched entries", ), ).toBe(true); }); it("logs lane-1 failures at debug and continues without trigger context", async () => { hoisted.getActiveMemorySearchManager.mockRejectedValueOnce(new Error("index unavailable")); await runPromptBuild( { prompt: "what did we decide?" }, { sessionKey: "agent:main:telegram:direct:owner", messageProvider: "telegram", channelId: "owner", }, ); expect(hasDebugLine("active-memory: lane-1 trigger recall failed: index unavailable")).toBe( true, ); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); }); it("lets active memory inherit the main QMD search mode when configured", async () => { api.config = { agents: { defaults: { model: { primary: "github-copilot/gpt-5.4-mini", }, }, }, memory: { backend: "qmd", qmd: { searchMode: "query", }, }, }; registerPluginConfig({ qmd: { searchMode: "inherit", }, }); await runPromptBuild({ prompt: "what wings should i order? inherit-qmd-mode-check", }); const config = embeddedRunConfig(); expect(config.memory).toEqual({ backend: "qmd", qmd: { searchMode: "query", }, }); expect(activeMemoryConfigFrom(config).qmd).toEqual({ searchMode: "inherit" }); }); it("frames the blocking memory subagent as a memory search agent for another model", async () => { await runPromptBuild({ prompt: "What is my favorite food? strict-style-check", }); const runParams = lastEmbeddedRunParams(); expect(runParams.prompt).toContain("You are a memory search agent."); expect(runParams.prompt).toContain("Another model is preparing the final user-facing answer."); expect(runParams.prompt).toContain( "Your job is to search memory and return only the most relevant memory context for that model.", ); expect(runParams.prompt).toContain( "You receive a bounded search query plus conversation context, including the user's latest message.", ); expect(runParams.prompt).toContain("Use only the available memory tools."); expect(runParams.prompt).toContain( "Use the bounded search query with the configured memory tools.", ); expect(runParams.prompt).toContain("Configured memory tools: memory_search, memory_get."); expect(runParams.prompt).toContain( "If the available memory tools find nothing useful, reply with NONE.", ); expect(runParams.prompt).not.toContain("memory_recall"); expect(runParams.toolsAllow).toEqual(["memory_search", "memory_get"]); expect(runParams.allowGatewaySubagentBinding).toBe(true); expect(runParams.prompt).toContain( "When searching for preference or habit recall, use permissive search limits or thresholds before deciding that no useful memory exists.", ); expect(runParams.prompt).toContain( "If the user is directly asking about favorites, preferences, habits, routines, or personal facts, treat that as a strong recall signal.", ); expect(runParams.prompt).toContain( "Questions like 'what is my favorite food', 'do you remember my flight preferences', or 'what do i usually get' should normally return memory when relevant results exist.", ); expect(runParams.prompt).toContain("Return exactly one of these two forms:"); expect(runParams.prompt).toContain("1. NONE"); expect(runParams.prompt).toContain("2. one compact plain-text summary"); expect(runParams.prompt).toContain( "Write the summary as a memory note about the user, not as a reply to the user.", ); expect(runParams.prompt).toContain( "Do not return bullets, numbering, labels, XML, JSON, or markdown list formatting.", ); expect(runParams.prompt).toContain("Good examples:"); expect(runParams.prompt).toContain("Bad examples:"); expect(runParams.prompt).toContain( "Return: User's favorite food is ramen; tacos also come up often.", ); }); it("passes custom configured memory tools and reflects them in the default prompt", async () => { registerPluginConfig({ toolsAllow: [" lcm_grep ", "lcm_describe", "", "lcm_expand_query", "lcm_grep"], }); await runPromptBuild({ prompt: "What did we decide about active memory?", }); const runParams = lastEmbeddedRunParams(); expect(runParams.toolsAllow).toEqual(["lcm_grep", "lcm_describe", "lcm_expand_query"]); expect(runParams.prompt).toContain( "Configured memory tools: lcm_grep, lcm_describe, lcm_expand_query.", ); expect(runParams.prompt).not.toContain("Prefer memory_recall"); expect(runParams.prompt).not.toContain("If memory_recall is unavailable"); }); it("uses memory_recall by default when the memory slot selects LanceDB", async () => { setMemorySlot("memory-lancedb"); await runPromptBuild({ prompt: "What did we decide about active memory?", }); const runParams = lastEmbeddedRunParams(); expect(runParams.toolsAllow).toEqual(["memory_recall"]); expect(runParams.prompt).toContain("Configured memory tools: memory_recall."); }); it("keeps explicit custom memory tools authoritative when the memory slot selects LanceDB", async () => { setMemorySlot("memory-lancedb"); api.pluginConfig = { agents: ["main"], toolsAllow: ["lcm_grep"], }; await runPromptBuild({ prompt: "What did we decide about active memory?", }); const runParams = lastEmbeddedRunParams(); expect(runParams.toolsAllow).toEqual(["lcm_grep"]); expect(runParams.prompt).toContain("Configured memory tools: lcm_grep."); }); it("drops wildcard group and core tools from custom memory tools", async () => { registerPluginConfig({ toolsAllow: [ "*", "agents_list", "apply_patch", "canvas", "cron", "edit", "gateway", "heartbeat_respond", "heartbeat_response", "image", "image_generate", "music_generate", "nodes", "pdf", "process", "session_status", "sessions_history", "sessions_list", "sessions_send", "sessions_spawn", "sessions_yield", "tts", "video_generate", "group:plugins", "read", "exec", "message", "lcm_grep", "web_search", "lcm_describe", ], }); await runPromptBuild({ prompt: "What did we decide about active memory?", }); const runParams = lastEmbeddedRunParams(); expect(runParams.toolsAllow).toEqual(["lcm_grep", "lcm_describe"]); expect(runParams.prompt).toContain("Configured memory tools: lcm_grep, lcm_describe."); }); it("falls back to default memory tools when custom memory tools only contain reserved entries", async () => { registerPluginConfig({ toolsAllow: ["*", "group:plugins", "read", "exec", "message", "web_search"], }); await runPromptBuild({ prompt: "What did we decide about active memory?", }); const runParams = lastEmbeddedRunParams(); expect(runParams.toolsAllow).toEqual(["memory_search", "memory_get"]); expect(runParams.prompt).toContain("Configured memory tools: memory_search, memory_get."); }); it("falls back to LanceDB compat tools when custom memory tools only contain reserved entries", async () => { setMemorySlot("memory-lancedb"); api.pluginConfig = { agents: ["main"], toolsAllow: ["*", "group:plugins", "read", "exec", "message", "web_search"], }; await runPromptBuild({ prompt: "What did we decide about active memory?", }); const runParams = lastEmbeddedRunParams(); expect(runParams.toolsAllow).toEqual(["memory_recall"]); expect(runParams.prompt).toContain("Configured memory tools: memory_recall."); }); it("defaults prompt style by query mode when no promptStyle is configured", async () => { registerPluginConfig({ queryMode: "message" }); await runPromptBuild({ prompt: "What is my favorite food? preference-style-check", }); const runParams = lastEmbeddedRunParams(); expect(runParams.prompt).toContain("Prompt style: strict."); expect(runParams.prompt).toContain( "If the latest user message does not strongly call for memory, reply with NONE.", ); }); it("honors an explicit promptStyle override", async () => { registerPluginConfig({ queryMode: "message", promptStyle: "preference-only" }); await runPromptBuild({ prompt: "What is my favorite food?", }); const runParams = lastEmbeddedRunParams(); expect(runParams.prompt).toContain("Prompt style: preference-only."); expect(runParams.prompt).toContain( "Optimize for favorites, preferences, habits, routines, taste, and recurring personal facts.", ); }); it("keeps thinking off and fast mode inherited by default but allows explicit overrides", async () => { await runPromptBuild({ prompt: "What is my favorite food? default-thinking-check", }); expect(lastEmbeddedRunParams().thinkLevel).toBe("off"); expect(lastEmbeddedRunParams().fastMode).toBeUndefined(); expect(lastEmbeddedRunParams().reasoningLevel).toBe("off"); registerPluginConfig({ thinking: "medium", fastMode: true, logging: true }); await runPromptBuild({ prompt: "What is my favorite food? thinking-override-check", }); expect(lastEmbeddedRunParams().thinkLevel).toBe("medium"); expect(lastEmbeddedRunParams().fastMode).toBe(true); expect(lastEmbeddedRunParams().reasoningLevel).toBe("off"); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(infoLines, "thinking=medium fast=on start"); }); it("inherits parent session fast mode before the agent default", async () => { api.config = { agents: { defaults: { model: { primary: "github-copilot/gpt-5.4-mini" }, }, list: [{ id: "main", fastModeDefault: true }], }, }; hoisted.sessionStore["agent:main:main"] = { sessionId: "s-main", updatedAt: 0, fastMode: false, }; registerPluginConfig({ agents: ["main"], logging: true }); await runPromptBuild({ prompt: "What is my favorite food? session-fast-mode-check", }); expect(lastEmbeddedRunParams().fastMode).toBe(false); let infoLines = vi.mocked(api.logger.info).mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(infoLines, "fast=off start"); delete hoisted.sessionStore["agent:main:main"].fastMode; await runPromptBuild({ prompt: "What is my favorite food? agent-fast-mode-check", }); expect(lastEmbeddedRunParams().fastMode).toBe(true); infoLines = vi.mocked(api.logger.info).mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(infoLines, "fast=on start"); }); it("allows appending extra prompt instructions without replacing the base prompt", async () => { registerPluginConfig({ promptAppend: "Prefer stable long-term preferences over one-off events.", }); await runPromptBuild({ prompt: "What is my favorite food? prompt-append-check", }); const prompt = lastEmbeddedPrompt(); expect(prompt).toContain("You are a memory search agent."); expect(prompt).toContain("Additional operator instructions:"); expect(prompt).toContain("Prefer stable long-term preferences over one-off events."); expect(prompt).toContain("Conversation context:"); expect(prompt).toContain("What is my favorite food? prompt-append-check"); }); it("allows replacing the base prompt while still appending conversation context", async () => { registerPluginConfig({ promptOverride: "Custom memory prompt. Return NONE or one user fact.", promptAppend: "Extra custom instruction.", }); await runPromptBuild({ prompt: "What is my favorite food? prompt-override-check", }); const prompt = lastEmbeddedPrompt(); expect(prompt).toContain("Custom memory prompt. Return NONE or one user fact."); expect(prompt).not.toContain("You are a memory search agent."); expect(prompt).toContain("Additional operator instructions:"); expect(prompt).toContain("Extra custom instruction."); expect(prompt).toContain("Conversation context:"); expect(prompt).toContain("What is my favorite food? prompt-override-check"); }); it("preserves leading digits in a plain-text summary", async () => { runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeUsableMemoryTranscript(params.sessionFile, "2024 trip to tokyo and 2% milk"); return { payloads: [{ text: "2024 trip to tokyo and 2% milk both matter here." }], }; }); const result = await runPromptBuild({ prompt: "what should i remember from my 2024 trip and should i buy 2% milk?", }); const prependContext = requirePrependContext(result); expect(prependContext).toContain("Context:"); expect(prependContext).toContain("2024 trip to tokyo"); expect(prependContext).toContain("2% milk"); }); it("preserves canonical parent session scope in the blocking memory subagent session key", async () => { await runPromptBuild( { prompt: "what should i grab on the way?" }, { sessionKey: "agent:main:telegram:direct:12345:thread:99", messageProvider: "telegram", channelId: "telegram", }, ); expect(lastEmbeddedSessionKey()).toMatch( /^agent:main:telegram:direct:12345:thread:99:active-memory:[a-f0-9]{12}$/, ); }); it("falls back to the current session model when no plugin model is configured", async () => { registerPluginConfig({}); await runPromptBuild( { prompt: "what wings should i order? temp transcript" }, { modelProviderId: "qwen", modelId: "glm-5", }, ); expect(lastEmbeddedRunParams().provider).toBe("qwen"); expect(lastEmbeddedRunParams().model).toBe("glm-5"); }); it("infers the configured provider for bare active-memory default models", async () => { api.config = { agents: { defaults: { model: { primary: "gpt-5.5" }, }, }, models: { providers: { openai: { baseUrl: "https://chatgpt.com/backend-api/codex", models: [ { id: "gpt-5.5", name: "GPT 5.5", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200_000, maxTokens: 128_000, }, ], }, }, }, }; registerPluginConfig({}); await runPromptBuild({ prompt: "what wings should i order? bare model default" }); expect(lastEmbeddedRunParams().provider).toBe("openai"); expect(lastEmbeddedRunParams().model).toBe("gpt-5.5"); }); it("skips recall when no model or explicit fallback resolves", async () => { api.config = {}; registerPluginConfig({ modelFallbackPolicy: "resolved-only" }); const result = await runPromptBuild( { prompt: "what wings should i order? no fallback" }, { sessionKey: "agent:main:resolved-only", }, ); expect(result).toBeUndefined(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("uses config.modelFallback when no session or agent model resolves", async () => { api.config = {}; registerPluginConfig({ modelFallback: "google/gemini-3-flash", modelFallbackPolicy: "default-remote", }); await runPromptBuild( { prompt: "what wings should i order? custom fallback" }, { sessionKey: "agent:main:custom-fallback", }, ); expect(lastEmbeddedRunParams().provider).toBe("google"); expect(lastEmbeddedRunParams().model).toBe("gemini-3-flash-preview"); expect(hasWarnLine("config.modelFallbackPolicy is deprecated")).toBe(true); // #74587: deprecation warning must spell out the chain-resolution // semantics so operators don't read it as a promise of runtime failover. // The previous wording ("set config.modelFallback if you want a fallback // model") cost real users hours of debug time before they hit the source // and saw `getModelRef` only walks candidates once. const warnCalls = (api.logger.warn as ReturnType).mock.calls; const deprecationMessage = warnCalls .map(([first]) => (typeof first === "string" ? first : "")) .find((message) => message.includes("config.modelFallbackPolicy is deprecated")); const message = requireNonEmptyString(deprecationMessage, "deprecation warning missing"); // Positive: the warning describes chain-resolution last-resort behavior. expect(message).toContain("chain-resolution"); expect(message).toContain("last-resort"); // Negative: the warning explicitly disclaims runtime failover, since // that's the wrong mental model the previous wording invited. expect(message).toMatch(/NOT a runtime failover/i); }); it("does not use a built-in fallback model even when default-remote is configured", async () => { api.config = {}; registerPluginConfig({ modelFallbackPolicy: "default-remote" }); const result = await runPromptBuild( { prompt: "what wings should i order? built-in fallback" }, { sessionKey: "agent:main:built-in-fallback", }, ); expect(result).toBeUndefined(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("persists a readable debug summary alongside the status line", async () => { const sessionKey = "agent:main:debug"; seedSession(sessionKey, "s-main", 0); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeUsableMemoryTranscript(params.sessionFile, "lemon pepper wings"); return { meta: { activeMemorySearchDebug: { backend: "qmd", configuredMode: "search", effectiveMode: "query", fallback: "unsupported-search-flags", searchMs: 2590, hits: 3, }, }, payloads: [{ text: "User prefers lemon pepper wings, and blue cheese still wins." }], }; }); await runPromptBuild( { prompt: "what wings should i order? debug telemetry", }, { sessionKey }, ); expect(hoisted.updateSessionStore).toHaveBeenCalled(); const updater = lastSessionStoreUpdater(); const store = { [sessionKey]: { sessionId: "s-main", updatedAt: 0, }, } as Record>; updater(store); const entries = store[sessionKey]?.pluginDebugEntries as | Array<{ pluginId?: string; lines?: string[] }> | undefined; expect(entries).toHaveLength(1); expect(entries?.[0]?.pluginId).toBe("active-memory"); expectLinesToContain(entries?.[0]?.lines ?? [], "🧩 Active Memory: status=ok"); expectLinesToContain( entries?.[0]?.lines ?? [], "🔎 Active Memory Debug: backend=qmd configuredMode=search effectiveMode=query fallback=unsupported-search-flags searchMs=2590 hits=3 | User prefers lemon pepper wings, and blue cheese still wins.", ); }); it("skips newest memory_search toolResult entries that carry no debug payload", async () => { const sessionKey = "agent:main:transcript-debug"; hoisted.sessionStore[sessionKey] = { sessionId: "s-main", updatedAt: 0 }; runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { const lines = [ JSON.stringify({ message: { role: "toolResult", toolName: "memory_search", details: { debug: { backend: "qmd", hits: 3 } }, }, }), JSON.stringify({ message: { role: "toolResult", toolName: "memory_search", details: {}, }, }), ]; await fs.writeFile(params.sessionFile, `${lines.join("\n")}\n`, "utf8"); return { payloads: [{ text: "wings are fine." }] }; }, ); await runPromptBuild({ prompt: "debug transcript bug" }, { sessionKey }); const updater = lastSessionStoreUpdater(); const store = { [sessionKey]: { sessionId: "s-main", updatedAt: 0 }, } as Record>; updater(store); const entries = store[sessionKey]?.pluginDebugEntries as | { pluginId: string; lines: string[] }[] | undefined; const debugLine = entries?.[0]?.lines.find((line) => line.startsWith("🔎 Active Memory Debug:"), ); const line = requireNonEmptyString(debugLine, "active memory debug line missing"); expect(line).toContain("backend=qmd"); expect(line).toContain("hits=3"); }); it("recognizes usable persisted memory results after details are capped", () => { const cappedDetails = { persistedDetailsTruncated: true, originalDetailKeys: ["results"], }; type MemoryResultCase = { toolName: string; details: Record; text: string | string[]; expected: boolean; }; const cases: MemoryResultCase[] = [ { toolName: "memory_search", details: cappedDetails, text: '{\n "results": [\n {"text": "ramen"}\n ]\n}', expected: true, }, { toolName: "memory_search", details: cappedDetails, text: '{\n "results": []\n}', expected: false, }, { toolName: "memory_recall", details: cappedDetails, text: "Found 2 memories:\n\n1. ramen\n2. chili crisp", expected: true, }, { toolName: "memory_recall", details: cappedDetails, text: "No relevant memories found.", expected: false, }, { toolName: "memory_get", details: { path: "memory/food.md", text: "User usually orders ramen." }, text: '{"text":"User usually orders ramen."}', expected: true, }, { toolName: "memory_lookup_custom", details: {}, text: "User usually orders ramen.", expected: true, }, { toolName: "memory_lookup_custom", details: { results: [] }, text: "No memories found.", expected: false, }, { toolName: "memory_lookup_custom", details: {}, text: ['{"status":"aborted"}', "The memory lookup was cancelled."], expected: false, }, { toolName: "memory_lookup_custom", details: {}, text: ['{"status":"not_found"}', "No memories found."], expected: false, }, { toolName: "memory_lookup_custom", details: {}, text: "[]", expected: false }, { toolName: "memory_lookup_custom", details: {}, text: ['{"results":[]}', "No matching memories were found."], expected: false, }, { toolName: "memory_lookup_custom", details: { results: [{ id: "ramen" }] }, text: '{"results":[{"content":"User usually orders ramen."}]}', expected: true, }, { toolName: "memory_lookup_custom", details: cappedDetails, text: '{"results":[{"content":"User usually orders ramen."}]}', expected: true, }, { toolName: "memory_lookup_custom", details: cappedDetails, text: '{"results":[]}', expected: false, }, { toolName: "lcm_grep", details: { totalMatches: 1, messageCount: 1, summaryCount: 0 }, text: "User usually orders ramen.", expected: true, }, { toolName: "lcm_grep", details: { totalMatches: 0, messageCount: 0, summaryCount: 0 }, text: "No matches found.", expected: false, }, { toolName: "lcm_describe", details: { id: "sum_123", type: "summary", summary: { tokenCount: 12 } }, text: "User usually orders ramen.", expected: true, }, { toolName: "lcm_expand_query", details: { answer: "User usually orders ramen.", expandedSummaryCount: 1, citedIds: ["sum_123"], }, text: '{"answer":"User usually orders ramen."}', expected: true, }, { toolName: "lcm_grep", details: { persistedDetailsTruncated: true, originalDetailKeys: ["totalMatches", "messages", "summaries"], }, text: "## LCM Grep Results\n**Pattern:** `ramen`\n**Total matches:** 2\n\n### Messages", expected: true, }, { toolName: "lcm_expand_query", details: { persistedDetailsTruncated: true, originalDetailKeys: ["answer", "expandedSummaryCount"], }, text: JSON.stringify({ answer: "User usually orders ramen.", expandedSummaryCount: 1, citedIds: ["sum_123"], }), expected: true, }, ]; const expectMemoryResult = ({ toolName, details, text, expected }: MemoryResultCase) => { const record = { message: { role: "toolResult", toolName, details, content: (Array.isArray(text) ? text : [text]).map((value) => ({ type: "text", text: value, })), }, }; const toolsAllow = toolName.startsWith("memory_") ? toolName === "memory_lookup_custom" ? [toolName] : undefined : [toolName]; expect(testing.hasUsableMemoryResultInSessionRecord(record, toolsAllow)).toBe(expected); }; for (const testCase of cases) { expectMemoryResult(testCase); } for (const status of [ "failed", "error", "failure", "timeout", "TIMED OUT", "timed_out", "timed-out", "unavailable", "disabled", "denied", "cancelled", "canceled", "aborted", "killed", "invalid", "forbidden", "blocked", ]) { expectMemoryResult({ toolName: "memory_lookup_custom", details: { status }, text: "The memory backend is unavailable.", expected: false, }); } for (const status of ["ok", "error_free", "not_failed", "not_cancelled"]) { expectMemoryResult({ toolName: "memory_lookup_custom", details: { status }, text: "User usually orders ramen.", expected: true, }); } for (const status of ["not_found", "empty", "no_results", "no_matches"]) { expectMemoryResult({ toolName: "memory_lookup_custom", details: { status }, text: "No memories found.", expected: false, }); } }); it("replaces stale structured active-memory lines on a later empty run", async () => { const sessionKey = "agent:main:stale-active-memory-lines"; hoisted.sessionStore[sessionKey] = { sessionId: "s-main", updatedAt: 0, pluginDebugEntries: [ { pluginId: "active-memory", lines: [ "🧩 Active Memory: status=ok elapsed=13.4s query=recent summary=34 chars", "🔎 Active Memory Debug: Favorite desk snack: roasted almonds or cashews.", ], }, { pluginId: "other-plugin", lines: ["Other Plugin: keep me"] }, ], }; runEmbeddedAgent.mockResolvedValueOnce({ payloads: [{ text: "NONE" }], }); await runPromptBuild({ prompt: "what's up with you?" }, { sessionKey }); const updater = lastSessionStoreUpdater(); const store = { [sessionKey]: { sessionId: "s-main", updatedAt: 0, pluginDebugEntries: [ { pluginId: "active-memory", lines: [ "🧩 Active Memory: status=ok elapsed=13.4s query=recent summary=34 chars", "🔎 Active Memory Debug: Favorite desk snack: roasted almonds or cashews.", ], }, { pluginId: "other-plugin", lines: ["Other Plugin: keep me"] }, ], }, } as Record>; updater(store); const pluginDebugEntries = store[sessionKey]?.pluginDebugEntries as | Array<{ pluginId?: string; lines?: string[] }> | undefined; expect(pluginDebugEntries).toHaveLength(2); expect(pluginDebugEntries?.[0]).toEqual({ pluginId: "other-plugin", lines: ["Other Plugin: keep me"], }); const activeMemoryLines = pluginDebugEntries?.[1]?.pluginId === "active-memory" ? pluginDebugEntries[1].lines : []; expectLinesToContain(activeMemoryLines ?? [], "🧩 Active Memory: status=no_relevant_memory"); }); it("returns nothing when the subagent says none", async () => { runEmbeddedAgent.mockResolvedValueOnce({ payloads: [{ text: "NONE" }], }); const result = await runPromptBuild({ prompt: "fair, okay gonna do them by throwing them in the garbage", }); expect(result).toBeUndefined(); }); it.each([ { name: "skips the recall subagent when no registered memory tools match", suffix: "missing-memory-tools", prompt: "what wings should i order? missing memory tools", }, { name: "skips missing memory tools when the allowlist error includes inherited sources", suffix: "missing-memory-tools-with-policy-source", prompt: "what wings should i order? missing memory tools with policy", sources: "tools.allow: *, lobster; runtime toolsAllow: memory_search, memory_get", }, { name: "skips missing custom memory tools using the resolved custom allowlist", suffix: "missing-custom-memory-tools", prompt: "what did we decide? missing custom memory tools", toolsAllow: ["lcm_grep", "lcm_describe", "lcm_expand_query"], }, { name: "skips memory-tool allowlist errors when upstream policy filters memory tools", suffix: "memory-tools-filtered-by-policy", prompt: "what wings should i order? memory tools filtered by policy", sources: "tools.allow: read, exec; runtime toolsAllow: memory_search, memory_get", }, ])("$name", async ({ suffix, prompt, sources, toolsAllow }) => { if (toolsAllow) { registerPluginConfig({ toolsAllow, logging: true }); } const sessionKey = `agent:main:${suffix}`; seedSession(sessionKey, `s-${suffix}`, 0); const error = makeMemoryToolAllowlistError( "no registered tools matched", sources ?? (toolsAllow ? `runtime toolsAllow: ${toolsAllow.join(", ")}` : undefined), ); expect(testing.isMissingRegisteredMemoryToolsError(error, toolsAllow)).toBe(true); runEmbeddedAgent.mockRejectedValueOnce(error); expect(await runPromptBuild({ prompt }, { sessionKey })).toBeUndefined(); expect(hasDebugLine("no configured memory tools available")).toBe(true); if (!toolsAllow) { expect(hasWarnLine("No callable tools remain")).toBe(false); } const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(1); expectLinesToContain(lines, "🧩 Active Memory: status=unavailable"); }); it.each([ ["disabled tools", "tools are disabled for this run"], ["models without tool support", "the selected model does not support tools"], ])( "skips allowlist errors for %s without surfacing to the main thread", async (_label, reason) => { const sessionKey = `agent:main:${reason.replace(/\W+/g, "-")}`; hoisted.sessionStore[sessionKey] = { sessionId: `s-${reason.replace(/\W+/g, "-")}`, updatedAt: 0, }; const error = makeMemoryToolAllowlistError(reason); expect(testing.isMissingRegisteredMemoryToolsError(error)).toBe(false); runEmbeddedAgent.mockRejectedValueOnce(error); const result = await runPromptBuild( { prompt: `what wings should i order? ${reason}` }, { sessionKey }, ); expect(result).toBeUndefined(); expect(hasDebugLine("no configured memory tools available")).toBe(false); expect(hasWarnLine(reason)).toBe(true); const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(1); expectLinesToContain(lines, "🧩 Active Memory: status=failed"); }, ); it("does not skip missing memory-tool allowlist errors after abort", async () => { const sessionKey = "agent:main:missing-memory-tools-after-abort"; seedSession(sessionKey, "s-missing-memory-tools-after-abort", 0); runEmbeddedAgent.mockImplementationOnce(async (params: { abortSignal?: AbortSignal }) => { Object.defineProperty(params.abortSignal as AbortSignal, "aborted", { configurable: true, value: true, }); throw makeMemoryToolAllowlistError("no registered tools matched"); }); const result = await runPromptBuild( { prompt: "what wings should i order? missing memory tools after abort" }, { sessionKey }, ); expect(result).toBeUndefined(); expect(hasDebugLine("no configured memory tools available")).toBe(false); const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(1); expectLinesToContain(lines, "🧩 Active Memory: status=timeout"); }); it("returns partial transcript text on timeout when the subagent has already written assistant output", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); testing.setTimeoutPartialDataGraceMsForTests(50); registerPluginConfig({ timeoutMs: 100, maxSummaryChars: 40, persistTranscripts: true, logging: true, }); const sessionKey = "agent:main:timeout-partial"; seedSession(sessionKey, "s-timeout-partial", 0); runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await writeTranscriptJsonl( params.sessionFile, [ { type: "message", message: { role: "user", content: "ignore this user text" } }, { type: "message", message: { role: "assistant", content: "alpha beta gamma delta" }, }, { type: "message", message: { role: "assistant", content: [{ type: "text", text: "epsilon zeta eta theta" }], }, }, ], "\n{", ); return await waitForAbort(params.abortSignal); }, ); const result = await runPromptBuild( { prompt: "what wings should i order? timeout partial" }, { sessionKey }, ); const prependContext = requirePrependContext(result); expect(prependContext).toContain("alpha beta gamma delta epsilon zeta eta…"); expect(prependContext).toContain(""); expect(prependContext).not.toContain("theta"); expect(prependContext).not.toContain("ignore this user text"); const lines = getActiveMemoryLines(sessionKey); expectLinesToContain(lines, "🧩 Active Memory: status=timeout_partial"); expectLinesToContain(lines, "summary=40 chars"); expectLinesToContain( lines, "🔎 Active Memory Debug: timeout_partial: 40 chars recovered (not persisted)", ); expect(lines.join("\n")).not.toContain("alpha beta gamma delta"); }); it("returns partial transcript text on timeout when transcripts are temporary by default", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); testing.setTimeoutPartialDataGraceMsForTests(50); registerPluginConfig({ timeoutMs: 100, maxSummaryChars: 80, logging: true }); const sessionKey = "agent:main:timeout-partial-temp-transcript"; seedSession(sessionKey, "s-timeout-partial-temp-transcript", 0); let tempSessionFile = ""; runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { tempSessionFile = params.sessionFile; await writeTranscriptJsonl(params.sessionFile, [ { type: "message", message: { role: "assistant", content: "temporary partial recall summary" }, }, ]); await waitForAbort(params.abortSignal); }, ); const result = await runPromptBuild( { prompt: "what wings should i order? timeout partial temp" }, { sessionKey }, ); expectPrependContextContains(result, "temporary partial recall summary"); await vi.waitFor(async () => { await expectPathMissing(tempSessionFile); }); const lines = getActiveMemoryLines(sessionKey); expectLinesToContain(lines, "🧩 Active Memory: status=timeout_partial"); expectLinesToContain( lines, "🔎 Active Memory Debug: timeout_partial: 32 chars recovered (not persisted)", ); }); it("returns partial transcript text on timeout from SQLite runtime transcript rows", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); testing.setTimeoutPartialDataGraceMsForTests(50); registerPluginConfig({ timeoutMs: 100, maxSummaryChars: 80, logging: true }); const sessionKey = "agent:main:timeout-partial-sqlite-transcript"; seedSession(sessionKey, "s-timeout-partial-sqlite-transcript", 0); let artifactSessionFile = ""; runEmbeddedAgent.mockImplementationOnce( async (params: { abortSignal?: AbortSignal; sessionFile?: string; sessionTarget?: { agentId: string; sessionId: string; sessionKey: string; storePath?: string; }; }) => { artifactSessionFile = params.sessionFile ?? ""; const target = params.sessionTarget; if (!target) { throw new Error("expected active-memory runtime session target"); } await appendSessionTranscriptMessageByIdentity({ ...target, message: { role: "assistant", content: "sqlite partial recall summary", }, }); await waitForAbort(params.abortSignal); }, ); const result = await runPromptBuild( { prompt: "what wings should i order? timeout partial sqlite" }, { sessionKey }, ); expectPrependContextContains(result, "sqlite partial recall summary"); expect(hoisted.rawDeltaReads).toContainEqual( expect.objectContaining({ maxBytes: 50 * 1024 * 1024, maxEvents: 2_000, }), ); if (artifactSessionFile) { await expectPathMissing(artifactSessionFile); } const runParams = lastEmbeddedRunParams(); expect(runParams.sessionTarget).toMatchObject({ agentId: "main", sessionKey: expect.stringMatching(/^agent:main:timeout-partial-sqlite-transcript:/), }); const lines = getActiveMemoryLines(sessionKey); expectLinesToContain(lines, "🧩 Active Memory: status=timeout_partial"); expectLinesToContain( lines, "🔎 Active Memory Debug: timeout_partial: 29 chars recovered (not persisted)", ); }); it("keeps timeout status when the timeout transcript is empty", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1, persistTranscripts: true, logging: true }); const sessionKey = "agent:main:timeout-empty-transcript"; seedSession(sessionKey, "s-timeout-empty-transcript", 0); runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await fs.writeFile(params.sessionFile, "", "utf8"); return await waitForAbort(params.abortSignal); }, ); const result = await runPromptBuild( { prompt: "what wings should i order? empty timeout transcript" }, { sessionKey }, ); expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(1); expectLinesToContain(lines, "🧩 Active Memory: status=timeout"); expectLinesNotToContain(lines, "timeout_partial"); }); it("keeps timeout status when the timeout transcript path does not exist", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1, persistTranscripts: true, logging: true }); const sessionKey = "agent:main:timeout-missing-transcript"; seedSession(sessionKey, "s-timeout-missing-transcript", 0); runEmbeddedAgent.mockImplementationOnce( async (params: { abortSignal?: AbortSignal }) => await waitForAbort(params.abortSignal), ); const result = await runPromptBuild( { prompt: "what wings should i order? missing timeout transcript" }, { sessionKey }, ); expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(1); expectLinesToContain(lines, "🧩 Active Memory: status=timeout"); expectLinesNotToContain(lines, "timeout_partial"); }); it("does not inject embedded timeout boilerplate from partial transcripts", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 100, logging: true }); const sessionKey = "agent:main:timeout-boilerplate-transcript"; seedSession(sessionKey, "s-timeout-boilerplate-transcript", 0); runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await writeTranscriptJsonl(params.sessionFile, [ { type: "message", message: { role: "assistant", content: "LLM request timed out after 15000 ms.", }, }, ]); await waitForAbort(params.abortSignal); }, ); const result = await runPromptBuild( { prompt: "what wings should i order? timeout boilerplate" }, { sessionKey, }, ); expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(1); expectLinesToContain(lines, "🧩 Active Memory: status=timeout"); expectLinesNotToContain(lines, "timeout_partial"); expectLinesNotToContain(lines, "LLM request timed out"); }); it("returns partial transcript text when an aborted subagent rejects before the race timeout wins", async () => { testing.setMinimumTimeoutMsForTests(1); registerPluginConfig({ timeoutMs: 5_000, persistTranscripts: true, logging: true }); const sessionKey = "agent:main:abort-timeout-partial"; seedSession(sessionKey, "s-abort-timeout-partial", 0); runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await writeTranscriptJsonl(params.sessionFile, [ { type: "message", message: { role: "assistant", content: "partial abort summary" }, }, ]); Object.defineProperty(params.abortSignal as AbortSignal, "aborted", { configurable: true, value: true, }); const abortErr = new Error("Operation aborted"); abortErr.name = "AbortError"; throw abortErr; }, ); const result = await runPromptBuild( { prompt: "what wings should i order? abort partial" }, { sessionKey }, ); expectPrependContextContains(result, "partial abort summary"); const lines = getActiveMemoryLines(sessionKey); expectLinesToContain(lines, "🧩 Active Memory: status=timeout_partial"); expectLinesToContain( lines, "🔎 Active Memory Debug: timeout_partial: 21 chars recovered (not persisted)", ); expect(getActiveMemoryLines(sessionKey).join("\n")).not.toContain("partial abort summary"); }); it("skips generic subagent errors without using partial transcript output", async () => { registerPluginConfig({ persistTranscripts: true, logging: true }); const sessionKey = "agent:main:generic-error-partial-ignored"; seedSession(sessionKey, "s-generic-error-partial-ignored", 0); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { type: "message", message: { role: "assistant", content: "must not be surfaced from generic errors" }, }, ]); throw new Error("synthetic failure"); }); const result = await runPromptBuild( { prompt: "what wings should i order? generic error" }, { sessionKey }, ); expect(result).toBeUndefined(); expectLinesToContain(getActiveMemoryLines(sessionKey), "🧩 Active Memory: status=failed"); expect(getActiveMemoryLines(sessionKey).join("\n")).not.toContain( "must not be surfaced from generic errors", ); const transcriptDir = path.join( stateDir, "plugins", "active-memory", "transcripts", "agents", "main", "active-memory", ); const transcriptPath = await expectSingleTranscriptArtifact(transcriptDir); expect(await fs.readFile(transcriptPath, "utf8")).toContain( "must not be surfaced from generic errors", ); }); it("bounds partial assistant transcript reads by character cap for large JSONL files", async () => { const sessionFile = path.join(stateDir, "large-timeout-transcript.jsonl"); await fs.mkdir(path.dirname(sessionFile), { recursive: true }); const line = `${JSON.stringify({ type: "message", message: { role: "assistant", content: "alpha beta gamma delta epsilon zeta eta theta", }, })}\n`; await fs.writeFile( sessionFile, line.repeat(Math.ceil((5 * 1024 * 1024) / line.length)), "utf8", ); const readFileSpy = vi.spyOn(fs, "readFile"); const result = await testing.readPartialAssistantText(sessionFile, { maxChars: 128, maxLines: 2_000, maxBytes: 10 * 1024 * 1024, }); const partialText = requireNonEmptyString(result, "partial assistant text missing"); expect(partialText.length).toBeLessThanOrEqual(128); expect(partialText).toContain("alpha beta gamma"); expect(readFileSpy).not.toHaveBeenCalled(); }); it("keeps partial assistant transcript caps UTF-16 safe", async () => { const sessionFile = path.join(stateDir, "surrogate-timeout-transcript.jsonl"); await writeTranscriptJsonl(sessionFile, [ { type: "message", message: { role: "assistant", content: `${"a".repeat(38)}🎉TAILWORD`, }, }, ]); const result = await testing.readPartialAssistantText(sessionFile, { maxChars: 39, maxLines: 10, }); expect(result).toBe("a".repeat(38)); }); it("keeps joined partial assistant transcript caps UTF-16 safe", async () => { const sessionFile = path.join(stateDir, "joined-surrogate-timeout-transcript.jsonl"); await writeTranscriptJsonl(sessionFile, [ { type: "message", message: { role: "assistant", content: "a".repeat(37) }, }, { type: "message", message: { role: "assistant", content: "🎉TAILWORD" }, }, ]); const result = await testing.readPartialAssistantText(sessionFile, { maxChars: 39, maxLines: 10, }); expect(result).toBe("a".repeat(37)); }); it("skips malformed JSONL lines when reading partial assistant transcripts", async () => { const sessionFile = path.join(stateDir, "malformed-timeout-transcript.jsonl"); await fs.mkdir(path.dirname(sessionFile), { recursive: true }); await fs.writeFile( sessionFile, [ "{not valid json", JSON.stringify({ type: "message", message: { role: "assistant", content: "valid partial summary" }, }), ].join("\n"), "utf8", ); const result = await testing.readPartialAssistantText(sessionFile, { maxChars: 200, maxLines: 10, }); expect(result).toBe("valid partial summary"); }); it("honors transcript maxLines caps for partial text and search debug reads", async () => { const sessionFile = path.join(stateDir, "max-lines-transcript.jsonl"); await writeTranscriptJsonl(sessionFile, [ { type: "message", message: { role: "user", content: "line one" }, }, { type: "message", message: { role: "assistant", content: "inside cap" }, }, { type: "message", message: { role: "assistant", content: "outside cap" }, }, { type: "message", message: { role: "toolResult", toolName: "memory_search", details: { debug: { backend: "qmd", effectiveMode: "search", hits: 1 }, }, }, }, ]); await expect( testing.readPartialAssistantText(sessionFile, { maxChars: 1_000, maxLines: 2, }), ).resolves.toBe("inside cap"); await expect( testing.readActiveMemorySearchDebug(sessionFile, { maxLines: 3, }), ).resolves.toBeUndefined(); const debug = await testing.readActiveMemorySearchDebug(sessionFile, { maxLines: 4, }); expect(debug?.backend).toBe("qmd"); expect(debug?.hits).toBe(1); }); it("caches ok summaries but not empty, no-relevant, or timeout_partial results", () => { expect( testing.shouldCacheResult({ status: "timeout_partial", elapsedMs: 1, summary: "partial summary", }), ).toBe(false); expect( testing.shouldCacheResult({ status: "ok", elapsedMs: 1, rawReply: "full summary", summary: "full summary", }), ).toBe(true); expect( testing.shouldCacheResult({ status: "empty", elapsedMs: 1, summary: null, }), ).toBe(false); expect( testing.shouldCacheResult({ status: "no_relevant_memory", elapsedMs: 1, summary: null, }), ).toBe(false); }); it("does not cache no-relevant-memory recall results", async () => { registerPluginConfig({ logging: true }); runEmbeddedAgent.mockResolvedValue({ payloads: [{ text: "NONE" }], }); await runPromptBuild( { prompt: "what wings should i order? empty cache" }, { sessionKey: "agent:main:empty-cache", }, ); await runPromptBuild( { prompt: "what wings should i order? empty cache" }, { sessionKey: "agent:main:empty-cache", }, ); expect(runEmbeddedAgent).toHaveBeenCalledTimes(2); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expect(infoLines.join("\n")).not.toContain("cached status="); }); it("surfaces timeout_partial summaries in status lines, metadata, and prompt prefixes", () => { const summary = "User prefers aisle seats."; const config = testing.normalizePluginConfig({ agents: ["main"], queryMode: "recent", }); const statusLine = testing.buildPluginStatusLine({ result: { status: "timeout_partial", elapsedMs: 1234, summary }, config, }); expect(statusLine).toContain("status=timeout_partial"); expect(statusLine).toContain(`summary=${summary.length} chars`); expect(testing.buildMetadata(summary)).toBe( "\nUser prefers aisle seats.\n", ); expect(testing.buildPromptPrefix(summary)).toBe( "Context:\n\nUser prefers aisle seats.\n", ); }); it("does not cache timeout results", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1, logging: true }); let lastAbortSignal: AbortSignal | undefined; runEmbeddedAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => { lastAbortSignal = params.abortSignal; return await new Promise((resolve, reject) => { const timer = setTimeout(() => { params.abortSignal?.removeEventListener("abort", abortHandler); resolve({ payloads: [] }); }, 2_000); const abortHandler = () => { clearTimeout(timer); reject(new Error("aborted")); }; params.abortSignal?.addEventListener("abort", abortHandler, { once: true }); }); }); await runPromptBuild( { prompt: "what wings should i order? timeout test" }, { sessionKey: "agent:main:timeout-test", }, ); await runPromptBuild( { prompt: "what wings should i order? timeout test" }, { sessionKey: "agent:main:timeout-test", }, ); expect(hoisted.updateSessionStore).toHaveBeenCalledTimes(2); expect(lastAbortSignal?.aborted).toBe(true); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesNotToContain(infoLines, " cached "); }); it("releases memory search managers after active-memory timeouts", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1, logging: true }); runEmbeddedAgent.mockImplementationOnce(() => new Promise(() => {})); const result = await runPromptBuild( { prompt: "what wings should i order? cleanup timeout" }, { sessionKey: "agent:main:cleanup-timeout", }, ); expect(result).toBeUndefined(); await vi.waitFor(() => { expect(hoisted.closeActiveMemorySearchManager).toHaveBeenCalled(); }); expect(hoisted.closeActiveMemorySearchManager).toHaveBeenCalledWith({ cfg: configFile, agentId: "main", }); }); it("schedules timeout cleanup before slow status persistence", async () => { vi.useFakeTimers(); testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1, logging: true }); runEmbeddedAgent.mockImplementationOnce(() => new Promise(() => {})); hoisted.updateSessionStore.mockImplementationOnce( async () => await new Promise((resolve) => { setTimeout(resolve, 5_000); }), ); const resultPromise = runPromptBuild( { prompt: "what wings should i order? slow timeout persistence" }, { sessionKey: "agent:main:slow-timeout-persistence", }, ); await vi.advanceTimersByTimeAsync(1_501); await expect(resultPromise).resolves.toBeUndefined(); expect(hoisted.closeActiveMemorySearchManager).toHaveBeenCalledTimes(1); }); it("does not clean up memory managers when only successful status persistence stalls", async () => { vi.useFakeTimers(); testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 25, logging: true }); let markPersistenceStarted: (() => void) | undefined; const persistenceStarted = new Promise((resolve) => { markPersistenceStarted = resolve; }); hoisted.updateSessionStore.mockImplementationOnce(async () => { markPersistenceStarted?.(); await new Promise((resolve) => { setTimeout(resolve, 5_000); }); }); const resultPromise = runPromptBuild( { prompt: "what wings should i order? slow successful persistence" }, { sessionKey: "agent:main:slow-success-persistence", }, ); await persistenceStarted; await vi.advanceTimersByTimeAsync(1_525); await expect(resultPromise).resolves.toBeUndefined(); expect(hoisted.closeActiveMemorySearchManager).not.toHaveBeenCalled(); }); it("does not share cached recall results across session-id-only contexts", async () => { registerPluginConfig({ logging: true }); await runPromptBuild( { prompt: "what wings should i order? session id cache" }, { sessionId: "session-a", }, ); await runPromptBuild( { prompt: "what wings should i order? session id cache" }, { sessionId: "session-b", }, ); const sessionKeys = runEmbeddedAgent.mock.calls.map( ([params]) => (params as { sessionKey?: string }).sessionKey, ); expect(new Set(sessionKeys).size).toBeGreaterThanOrEqual(2); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesNotToContain(infoLines, " cached "); }); it("ignores late subagent payloads once the active-memory timeout signal has fired", async () => { const CONFIGURED_TIMEOUT_MS = 25; testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: CONFIGURED_TIMEOUT_MS, logging: true }); runEmbeddedAgent.mockImplementationOnce(async (params: { timeoutMs?: number }) => { await new Promise((resolve) => { setTimeout(resolve, (params.timeoutMs ?? 0) + 5); }); return { payloads: [{ text: "late timeout payload that should never become memory context" }], meta: { aborted: true }, }; }); const result = await runPromptBuild( { prompt: "what wings should i order? late payload timeout" }, { sessionKey: "agent:main:late-timeout-payload", }, ); expect(result).toBeUndefined(); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(infoLines, "status=timeout"); expect( infoLines.filter( (line: string) => line.includes("activeProvider=github-copilot") && line.includes("activeModel=gpt-5.4-mini"), ), ).not.toEqual([]); }); it("does not spend the model timeout budget on active-memory subagent setup", async () => { const CONFIGURED_TIMEOUT_MS = 25; const SETUP_GRACE_TIMEOUT_MS = 50; testing.setMinimumTimeoutMsForTests(1); registerPluginConfig({ timeoutMs: CONFIGURED_TIMEOUT_MS, setupGraceTimeoutMs: SETUP_GRACE_TIMEOUT_MS, logging: true, }); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await new Promise((resolve) => { setTimeout(resolve, CONFIGURED_TIMEOUT_MS + 5); }); await writeUsableMemoryTranscript(params.sessionFile, "remember the ramen place"); return { payloads: [{ text: "remember the ramen place" }] }; }); const result = await runPromptBuild( { prompt: "what wings should i order? setup grace" }, { sessionKey: "agent:main:setup-grace", }, ); expect(result?.prependContext).toContain("remember the ramen place"); expect(lastEmbeddedRunParams().timeoutMs).toBe(CONFIGURED_TIMEOUT_MS + SETUP_GRACE_TIMEOUT_MS); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesNotToContain(infoLines, "status=timeout"); }); it("returns timeout within a hard deadline even when the subagent never checks the abort signal", async () => { const CONFIGURED_TIMEOUT_MS = 25; const HARD_DEADLINE_MARGIN_MS = 1_500; testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: CONFIGURED_TIMEOUT_MS, logging: true }); // Simulate a subagent that never cooperatively checks the abort signal. runEmbeddedAgent.mockImplementationOnce(() => new Promise(() => {})); const startedAt = Date.now(); const result = await runPromptBuild( { prompt: "what wings should i order? hard deadline test" }, { sessionKey: "agent:main:hard-deadline", }, ); const wallClockMs = Date.now() - startedAt; // The hook returns undefined for timeout results (summary is null). expect(result).toBeUndefined(); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(infoLines, "status=timeout"); // Hard deadline: wall-clock time must be near timeoutMs, not 30s. expect(wallClockMs).toBeLessThan(CONFIGURED_TIMEOUT_MS + HARD_DEADLINE_MARGIN_MS); }); it("does not fast-fail terminal zero-hit memory_search results as empty", async () => { const CONFIGURED_TIMEOUT_MS = 50; testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); testing.setTimeoutPartialDataGraceMsForTests(50); registerPluginConfig({ timeoutMs: CONFIGURED_TIMEOUT_MS, logging: true }); const sessionKey = "agent:main:terminal-zero-hit"; hoisted.sessionStore[sessionKey] = { sessionId: "s-terminal-zero-hit", updatedAt: 0 }; runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { results: [], debug: { backend: "qmd", hits: 0, searchMs: 8 } }, }, }, ]); await waitForAbort(params.abortSignal); }, ); const result = await runPromptBuild( { prompt: "what food do i usually order? zero hit" }, { sessionKey }, ); expect(result).toBeUndefined(); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(infoLines, "done status=timeout"); expectLinesNotToContain(infoLines, "done status=empty"); const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(2); expectLinesToContain(lines, "🧩 Active Memory: status=timeout"); expectLinesToContain(lines, "🔎 Active Memory Debug: backend=qmd searchMs=8 hits=0"); }); it("does not fast-fail memory_search results solely because debug hits is zero", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 100, logging: true }); const sessionKey = "agent:main:terminal-zero-hit-with-results"; seedSession(sessionKey, "s-terminal-zero-hit-with-results", 0); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { results: [{ path: "memory/food.md", text: "User usually orders ramen." }], debug: { backend: "qmd", hits: 0, searchMs: 8 }, }, }, }, ]); await new Promise((resolve) => { setTimeout(resolve, 35); }); return { payloads: [{ text: "User usually orders ramen." }] }; }); const result = await runPromptBuild( { prompt: "what food do i usually order? zero hit with results" }, { sessionKey }, ); expect(requirePrependContext(result)).toContain("User usually orders ramen."); const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(2); expectLinesToContain(lines, "🧩 Active Memory: status=ok"); expectLinesToContain(lines, "🔎 Active Memory Debug: backend=qmd searchMs=8 hits=0"); }); it("uses a late verbose summary after a successful result and later unavailable trace", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); const CONFIGURED_TIMEOUT_MS = 1_000; testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); testing.setTimeoutPartialDataGraceMsForTests(5); registerPluginConfig({ timeoutMs: CONFIGURED_TIMEOUT_MS, maxSummaryChars: 120, logging: true }); const sessionKey = "agent:main:terminal-unavailable-then-summary"; seedSession(sessionKey, "s-terminal-unavailable-then-summary", 0); const verboseSummary = "This memory says the user usually orders tonkotsu ramen, keeps chili crisp nearby, and prefers short dinner suggestions without menu preamble."; let markDelayScheduled: (() => void) | undefined; const delayScheduled = new Promise((resolve) => { markDelayScheduled = resolve; }); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { persistedDetailsTruncated: true, originalDetailKeys: ["results", "debug"], }, content: [ { type: "text", text: JSON.stringify( { results: [ { path: "memory/food.md", text: "User usually orders tonkotsu ramen." }, ], debug: { backend: "qmd", hits: 1, searchMs: 8 }, }, null, 2, ), }, ], }, }, { message: { role: "toolResult", toolName: "memory_search", details: { disabled: true, warning: "Memory search is unavailable due to an embedding/provider error.", action: "Check the embedding provider configuration, then retry memory_search.", error: "embedding request failed", }, }, }, ]); markDelayScheduled?.(); await new Promise((resolve) => { setTimeout(resolve, 550); }); const activeSessionFile = path.join(path.dirname(params.sessionFile), "rotated.jsonl"); await writeTranscriptJsonl(activeSessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { disabled: true, error: "embedding request failed", }, }, }, ]); return { payloads: [{ text: verboseSummary }], meta: { agentMeta: { sessionFile: activeSessionFile } }, }; }); const resultPromise = runPromptBuild( { prompt: "what food do i usually order? unavailable then summary" }, { sessionKey }, ); await delayScheduled; await vi.advanceTimersByTimeAsync(550); const result = await resultPromise; expect(requirePrependContext(result)).toContain( "This memory says the user usually orders tonkotsu ramen", ); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(infoLines, "done status=ok"); expectLinesNotToContain(infoLines, "done status=unavailable"); const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(2); expectLinesToContain(lines, "Active Memory: status=ok"); }); it("does not recover transcript partials after a later unavailable search times out", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); testing.setTimeoutPartialDataGraceMsForTests(50); registerPluginConfig({ timeoutMs: 100, logging: true }); const sessionKey = "agent:main:grounded-terminal-timeout-partial"; seedSession(sessionKey, "s-grounded-terminal-timeout-partial", 0); runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "assistant", content: "I will inspect memory before answering.", }, }, { message: { role: "toolResult", toolName: "memory_search", details: { results: [{ path: "memory/food.md", text: "User usually orders ramen." }], }, }, }, { message: { role: "toolResult", toolName: "memory_search", details: { disabled: true, warning: "Memory search is disabled for this session.", }, }, }, ]); return await waitForAbort(params.abortSignal); }, ); const result = await runPromptBuild( { prompt: "what food do i usually order? grounded timeout" }, { sessionKey }, ); expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expectLinesToContain(lines, "Active Memory: status=timeout"); expectLinesNotToContain(lines, "timeout_partial"); }); it("does not recover a timeout partial when unavailable debug arrives after the last poll", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); testing.setTimeoutPartialDataGraceMsForTests(50); registerPluginConfig({ timeoutMs: 100, logging: true }); const sessionKey = "agent:main:late-unavailable-timeout"; seedSession(sessionKey, "s-late-unavailable-timeout", 0); runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await new Promise((resolve) => { if (params.abortSignal?.aborted) { resolve(); return; } params.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); }); await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { disabled: true, warning: "Memory search is disabled for this session.", }, }, }, { message: { role: "assistant", content: "This text must not become recalled context.", }, }, ]); return { payloads: [] }; }, ); const result = await runPromptBuild( { prompt: "what food do i usually order? late unavailable" }, { sessionKey }, ); expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expectLinesToContain(lines, "Active Memory: status=timeout"); expectLinesNotToContain(lines, "timeout_partial"); }); it("does not recover a timeout partial while abort cleanup is still settling", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); testing.setTimeoutPartialDataGraceMsForTests(50); registerPluginConfig({ timeoutMs: 100, logging: true }); const sessionKey = "agent:main:unsettled-timeout"; seedSession(sessionKey, "s-unsettled-timeout", 0); let resolveLateWrite: () => void = () => {}; const lateWriteDone = new Promise((resolve) => { resolveLateWrite = resolve; }); let releaseLateWrite: () => void = () => {}; const lateWriteRelease = new Promise((resolve) => { releaseLateWrite = resolve; }); runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await new Promise((resolve) => { if (params.abortSignal?.aborted) { resolve(); return; } params.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); }); await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "assistant", content: "This unsettled text must not become recalled context.", }, }, ]); await lateWriteRelease; await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "assistant", content: "This unsettled text must not become recalled context.", }, }, { message: { role: "toolResult", toolName: "memory_search", details: { disabled: true }, }, }, ]); resolveLateWrite(); return { payloads: [] }; }, ); try { const result = await runPromptBuild( { prompt: "what food do i usually order? unsettled timeout" }, { sessionKey }, ); expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expectLinesToContain(lines, "Active Memory: status=timeout"); expectLinesNotToContain(lines, "timeout_partial"); } finally { releaseLateWrite(); await lateWriteDone; } }); it("does not recover a timeout partial after an unmirrored custom memory tool fails", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); testing.setTimeoutPartialDataGraceMsForTests(50); registerPluginConfig({ timeoutMs: 100, toolsAllow: ["memory_lookup_custom"], logging: true }); const sessionKey = "agent:main:custom-tool-timeout-failure"; seedSession(sessionKey, "s-custom-tool-timeout-failure", 0); runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal; onAgentToolResult?: (event: { toolName: string; result: unknown; isError: boolean; }) => void; }) => { params.onAgentToolResult?.({ toolName: "memory_lookup_custom", isError: true, result: { content: [{ type: "text", text: "upstream unavailable" }], details: { status: "failed", error: "upstream unavailable" }, }, }); await new Promise((resolve) => { if (params.abortSignal?.aborted) { resolve(); return; } params.abortSignal?.addEventListener("abort", () => resolve(), { once: true }); }); await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "assistant", content: "This custom-tool failure must not become recalled context.", }, }, ]); return { payloads: [] }; }, ); const result = await runPromptBuild( { prompt: "what food do i usually order? custom timeout failure" }, { sessionKey }, ); expect(result).toBeUndefined(); const lines = getActiveMemoryLines(sessionKey); expectLinesToContain(lines, "Active Memory: status=timeout"); expectLinesNotToContain(lines, "timeout_partial"); }); it("waits for configured custom-tool evidence after memory_search fails", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1_000, toolsAllow: ["memory_lookup_custom", "memory_search"], logging: true, }); const sessionKey = "agent:main:custom-tool-evidence"; seedSession(sessionKey, "s-custom-tool-evidence", 0); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { disabled: true, error: "embedding request failed" }, }, }, ]); await new Promise((resolve) => { setTimeout(resolve, 75); }); await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { disabled: true, error: "embedding request failed" }, }, }, { message: { role: "toolResult", toolName: "memory_lookup_custom", details: { persistedDetailsTruncated: true, success: true, originalDetailKeys: ["success", "results"], }, content: [ { type: "text", text: "User usually orders ramen.", }, ], }, }, ]); return { payloads: [{ text: "User usually orders ramen." }] }; }); const result = await runPromptBuild( { prompt: "what food do i usually order? custom evidence" }, { sessionKey }, ); expectPrependContextContains(result, "User usually orders ramen."); expectLinesToContain(getActiveMemoryLines(sessionKey), "Active Memory: status=ok"); }); it("matches configured memory tool names case-insensitively", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1_000, toolsAllow: [" MEMORY_SEARCH "], logging: true }); const sessionKey = "agent:main:case-insensitive-tool-evidence"; seedSession(sessionKey, "s-case-insensitive-tool-evidence", 0); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeUsableMemoryTranscript(params.sessionFile, "User usually orders ramen."); return { payloads: [{ text: "User usually orders ramen." }] }; }); const result = await runPromptBuild( { prompt: "what food do i usually order? case insensitive" }, { sessionKey }, ); expect(lastEmbeddedRunParams().toolsAllow).toEqual(["memory_search"]); expectPrependContextContains(result, "User usually orders ramen."); }); it("allows a configured custom tool to succeed after a failed attempt", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1_000, toolsAllow: ["memory_lookup_custom"], logging: true }); const sessionKey = "agent:main:custom-tool-retry"; seedSession(sessionKey, "s-custom-tool-retry", 0); const failedResult = { message: { role: "toolResult", toolName: "memory_lookup_custom", details: { status: "failed", error: "query was too broad" }, }, }; runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [failedResult]); await new Promise((resolve) => { setTimeout(resolve, 75); }); await writeTranscriptJsonl(params.sessionFile, [ failedResult, { message: { role: "toolResult", toolName: "memory_lookup_custom", details: { status: "success", results: [{ text: "User usually orders ramen." }] }, content: [{ type: "text", text: "User usually orders ramen." }], }, }, ]); return { payloads: [{ text: "User usually orders ramen." }] }; }); const result = await runPromptBuild( { prompt: "what food do i usually order? custom retry" }, { sessionKey }, ); expectPrependContextContains(result, "User usually orders ramen."); expectLinesToContain(getActiveMemoryLines(sessionKey), "Active Memory: status=ok"); }); it("uses harness-native tool results when the runtime does not mirror them to the transcript", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1_000, toolsAllow: ["memory_search"], logging: true }); const sessionKey = "agent:main:harness-tool-evidence"; seedSession(sessionKey, "s-harness-tool-evidence", 0); runEmbeddedAgent.mockImplementationOnce( async (params: { onAgentToolResult?: (event: { toolName: string; result: unknown; isError: boolean; }) => void; }) => { params.onAgentToolResult?.({ toolName: "memory_search", isError: false, result: { content: [ { type: "text", text: '{"results":[{"text":"User usually orders ramen."}]}', }, ], details: { results: [{ text: "User usually orders ramen." }] }, }, }); return { payloads: [{ text: "User usually orders ramen." }] }; }, ); const result = await runPromptBuild( { prompt: "what food do i usually order? harness evidence" }, { sessionKey }, ); expectPrependContextContains(result, "User usually orders ramen."); expectLinesToContain(getActiveMemoryLines(sessionKey), "Active Memory: status=ok"); }); it("rejects completed output after a configured custom tool reports a content-only timeout", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1_000, toolsAllow: ["memory_lookup_custom"], logging: true }); const sessionKey = "agent:main:custom-tool-content-failure"; seedSession(sessionKey, "s-custom-tool-content-failure", 0); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "toolResult", toolName: "memory_lookup_custom", details: { success: true }, content: [ { type: "text", text: '{"status":"timed_out"}', }, { type: "text", text: "The custom backend returned a diagnostic.", }, ], }, }, ]); return { payloads: [{ text: "This ungrounded summary must not become recalled context." }] }; }); const result = await runPromptBuild( { prompt: "what food do i usually order? custom content failure" }, { sessionKey }, ); expect(result).toBeUndefined(); expectLinesToContain(getActiveMemoryLines(sessionKey), "Active Memory: status=unavailable"); }); it("fails open at the live deadline when pre-recall session state stalls", async () => { vi.useFakeTimers(); testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); api.pluginConfig = { agents: ["main"], timeoutMs: 25, logging: true, }; let resolveLookup: ((value: undefined) => void) | undefined; vi.spyOn(api.runtime.state, "openKeyedStore").mockReturnValue({ lookup: () => new Promise((resolve) => { resolveLookup = resolve; }), }); plugin.register(api as unknown as OpenClawPluginApi); const resultPromise = runPromptBuild( { prompt: "what food do i usually order? stalled toggle lookup" }, { sessionKey: "agent:main:stalled-toggle", }, ); await vi.advanceTimersByTimeAsync(1_525); await expect(resultPromise).resolves.toBeUndefined(); expect(runEmbeddedAgent).not.toHaveBeenCalled(); const warnLines = vi .mocked(api.logger.warn) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(warnLines, "before_prompt_build preflight timed out after 1500ms"); resolveLookup?.(undefined); await vi.advanceTimersByTimeAsync(0); expect(runEmbeddedAgent).not.toHaveBeenCalled(); }); it("preserves recall settlement time after near-limit preflight latency", async () => { vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); testing.setTimeoutPartialDataGraceMsForTests(100); api.pluginConfig = { agents: ["main"], timeoutMs: 25, logging: true, }; vi.spyOn(api.runtime.state, "openKeyedStore").mockReturnValue({ lookup: async () => await new Promise((resolve) => { setTimeout(() => resolve(undefined), 1_490); }), }); plugin.register(api as unknown as OpenClawPluginApi); runEmbeddedAgent.mockImplementationOnce(() => new Promise(() => {})); const resultPromise = runPromptBuild( { prompt: "what food do i usually order? delayed recall start" }, { sessionKey: "agent:main:delayed-recall-start", }, ); await vi.advanceTimersByTimeAsync(1_490); const hasStartedRecall = () => vi .mocked(api.logger.info) .mock.calls.some((call: unknown[]) => String(call[0]).includes("session=agent:main:delayed-recall-start"), ); for (let attempt = 0; attempt < 20 && !hasStartedRecall(); attempt += 1) { await new Promise((resolve) => { setImmediate(resolve); }); } expect(hasStartedRecall()).toBe(true); await vi.advanceTimersByTimeAsync(25); await vi.advanceTimersByTimeAsync(1_499); await expect(resultPromise).resolves.toBeUndefined(); expect(hoisted.closeActiveMemorySearchManager).toHaveBeenCalled(); const circuitBreakerKey = testing.buildCircuitBreakerKey( "main", "github-copilot", "gpt-5.4-mini", ); expect(testing.isCircuitBreakerOpen(circuitBreakerKey, 1, 60_000)).toBe(true); }); it("rejects completed output after a memory search returns no recall evidence", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1_000, logging: true }); const sessionKey = "agent:main:empty-search-completed-output"; seedSession(sessionKey, "s-empty-search-completed-output", 0); runEmbeddedAgent.mockImplementation(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { results: [] }, content: [{ type: "text", text: '{"results":[]}' }], }, }, ]); return { payloads: [{ text: "This ungrounded summary must not become recalled context." }] }; }); const result = await runPromptBuild( { prompt: "what food do i usually order? empty search" }, { sessionKey }, ); expect(result).toBeUndefined(); expectLinesToContain(getActiveMemoryLines(sessionKey), "status=no_relevant_memory"); }); it("does not recover arbitrary assistant text without successful memory evidence", async () => { const CONFIGURED_TIMEOUT_MS = 1_000; testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); testing.setTimeoutPartialDataGraceMsForTests(200); registerPluginConfig({ timeoutMs: CONFIGURED_TIMEOUT_MS, logging: true }); const sessionKey = "agent:main:terminal-unavailable-then-diagnostic"; seedSession(sessionKey, "s-terminal-unavailable-then-diagnostic", 0); const warning = "Memory search is unavailable due to an embedding/provider error."; const action = "Check the embedding provider configuration, then retry memory_search."; runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { disabled: true, warning, action, error: "embedding request failed", }, }, }, ]); return { payloads: [{ text: "User usually orders tonkotsu ramen." }] }; }); const result = await runPromptBuild( { prompt: "what food do i usually order? unavailable diagnostic" }, { sessionKey }, ); expect(result).toBeUndefined(); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(infoLines, "done status=unavailable"); expectLinesNotToContain(infoLines, "done status=ok"); const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(2); expectLinesToContain(lines, "Active Memory: status=unavailable"); expectLinesToContain(lines, `Active Memory Debug: ${warning} ${action}`); }); it("uses configured memory evidence from a rotated embedded transcript", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1_000, toolsAllow: ["memory_get", "memory_search"], logging: true, }); const sessionKey = "agent:main:rotated-memory-evidence"; seedSession(sessionKey, "s-rotated-memory-evidence", 0); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "toolResult", toolName: "memory_get", details: { path: "memory/food.md", text: "User usually orders ramen." }, }, }, ]); const activeSessionFile = path.join(path.dirname(params.sessionFile), "rotated.jsonl"); await writeTranscriptJsonl(activeSessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { disabled: true, error: "embedding request failed", }, }, }, ]); return { payloads: [{ text: "User usually orders ramen." }], meta: { agentMeta: { sessionFile: activeSessionFile } }, }; }); const result = await runPromptBuild( { prompt: "what food do i usually order? rotated transcript" }, { sessionKey }, ); expectPrependContextContains(result, "User usually orders ramen."); expectLinesToContain(getActiveMemoryLines(sessionKey), "status=ok"); }); it("rejects completed output when only a rotated transcript reports unavailable memory", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1_000, logging: true }); const sessionKey = "agent:main:rotated-memory-unavailable"; seedSession(sessionKey, "s-rotated-memory-unavailable", 0); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { const activeSessionFile = path.join(path.dirname(params.sessionFile), "rotated.jsonl"); await writeTranscriptJsonl(activeSessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { disabled: true, warning: "Memory search is disabled for this session.", }, }, }, ]); return { payloads: [{ text: "This arbitrary output must not become recalled context." }], meta: { agentMeta: { sessionFile: activeSessionFile } }, }; }); const result = await runPromptBuild( { prompt: "what food do i usually order? rotated unavailable" }, { sessionKey }, ); expect(result).toBeUndefined(); expectLinesToContain(getActiveMemoryLines(sessionKey), "status=unavailable"); }); it("rejects completed output when a rotated SQLite transcript reports unavailable memory", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1_000, logging: true }); const sessionKey = "agent:main:rotated-sqlite-memory-unavailable"; seedSession(sessionKey, "s-rotated-sqlite-memory-unavailable", 0); runEmbeddedAgent.mockImplementationOnce( async (params: { sessionTarget?: { agentId: string; sessionId: string; sessionKey: string; storePath?: string; }; }) => { const target = params.sessionTarget; if (!target?.storePath) { throw new Error("expected active-memory SQLite runtime target"); } const rotatedTarget = { ...target, sessionId: "s-rotated-sqlite-memory-unavailable-next", }; await appendSessionTranscriptMessageByIdentity({ ...rotatedTarget, message: { role: "toolResult", toolCallId: "memory-search-1", toolName: "memory_search", isError: true, content: [], details: { disabled: true, warning: "Memory search is disabled for this session.", }, }, }); return { payloads: [{ text: "This arbitrary output must not become recalled context." }], meta: { agentMeta: { sessionFile: `sqlite:${rotatedTarget.agentId}:${rotatedTarget.sessionId}:${rotatedTarget.storePath}`, }, }, }; }, ); const result = await runPromptBuild( { prompt: "what food do i usually order? rotated sqlite unavailable" }, { sessionKey }, ); expect(result).toBeUndefined(); expectLinesToContain(getActiveMemoryLines(sessionKey), "status=unavailable"); }); it("fast-fails configured-provider-missing memory_search results without injecting provider errors", async () => { const CONFIGURED_TIMEOUT_MS = 1_000; testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: CONFIGURED_TIMEOUT_MS, logging: true }); const sessionKey = "agent:main:terminal-unavailable"; hoisted.sessionStore[sessionKey] = { sessionId: "s-terminal-unavailable", updatedAt: 0 }; runEmbeddedAgent.mockImplementationOnce( async (params: { sessionFile: string; abortSignal?: AbortSignal }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "toolResult", toolName: "memory_search", details: { disabled: true, warning: "Memory search is unavailable due to an embedding/provider error.", action: "Check the embedding provider configuration, then retry memory_search.", error: 'Memory search unavailable: embedding provider "openai" is configured but unavailable.', }, }, }, ]); await waitForAbort(params.abortSignal); }, ); const result = await runPromptBuild( { prompt: "what food do i usually order? unavailable" }, { sessionKey }, ); expect(result).toBeUndefined(); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(infoLines, "done status=unavailable"); expectLinesNotToContain(infoLines, "done status=timeout"); const lines = getActiveMemoryLines(sessionKey); expect(lines).toHaveLength(2); expectLinesToContain(lines, "🧩 Active Memory: status=unavailable"); expectLinesToContain( lines, "🔎 Active Memory Debug: Memory search is unavailable due to an embedding/provider error. Check the embedding provider configuration, then retry memory_search.", ); }); it("does not fast-fail memory_get misses but rejects ungrounded completed output", async () => { testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: 1_000 }); seedSession("agent:main:memory-get-miss", "s-memory-get-miss", 0); runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeTranscriptJsonl(params.sessionFile, [ { message: { role: "toolResult", toolName: "memory_get", details: { path: "memory/missing.md", text: "", disabled: true, error: "not found" }, }, }, ]); await new Promise((resolve) => { setTimeout(resolve, 35); }); return { payloads: [{ text: "User usually orders ramen after late flights." }] }; }); const result = await runPromptBuild( { prompt: "what food do i usually order? memory get miss" }, { sessionKey: "agent:main:memory-get-miss", }, ); expect(result).toBeUndefined(); expectLinesToContain(getActiveMemoryLines("agent:main:memory-get-miss"), "status=unavailable"); }); it("returns undefined instead of throwing when an unexpected error escapes prompt building", async () => { const result = await runPromptBuild( { prompt: "what should i eat? escape test", messages: undefined as never }, { sessionKey: "agent:main:escape-test", }, ); expect(result).toBeUndefined(); const warnLines = vi .mocked(api.logger.warn) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(warnLines, "before_prompt_build"); }); it("honors configured timeoutMs values above the former 60 000 ms ceiling", async () => { registerPluginConfig({ timeoutMs: 90_000, logging: true }); await runPromptBuild( { prompt: "what wings should i order? high timeout" }, { sessionKey: "agent:main:high-timeout", }, ); const passedTimeoutMs = lastEmbeddedRunParams().timeoutMs; expect(passedTimeoutMs).toBe(90_000); }); it("clamps timeoutMs above the 120 000 ms ceiling to the ceiling", async () => { registerPluginConfig({ timeoutMs: 200_000, logging: true }); await runPromptBuild( { prompt: "what wings should i order? capped timeout" }, { sessionKey: "agent:main:capped-timeout", }, ); const passedTimeoutMs = lastEmbeddedRunParams().timeoutMs; expect(passedTimeoutMs).toBe(120_000); }); it("sanitizes active-memory log fields onto a single line", async () => { registerPluginConfig({ logging: true }); await runPromptBuild( { prompt: "what wings should i order? log sanitization" }, { sessionKey: "agent:main:webchat:direct:12345\nforged", modelProviderId: "github-copilot\nshadow", modelId: "gpt-5.4-mini\tlane", }, ); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expect( infoLines.filter( (line: string) => line.includes("agent=main") && line.includes("session=agent:main:webchat:direct:12345 forged") && line.includes("activeProvider=github-copilot shadow") && line.includes("activeModel=gpt-5.4-mini lane") && !/[\r\n\t]/.test(line), ), ).not.toEqual([]); }); it("caps active-memory log field lengths without splitting surrogate pairs", async () => { registerPluginConfig({ logging: true }); const sessionPrefix = `agent:main:${"x".repeat(288)}`; const hugeSession = `${sessionPrefix}😀tail`; await runPromptBuild( { prompt: "what wings should i order? long log value" }, { sessionKey: hugeSession, }, ); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); const startLine = infoLines.find((line: string) => line.includes(" start timeoutMs=")); const line = requireNonEmptyString(startLine, "active memory start log line missing"); expect(line.length).toBeLessThan(500); expect(line).toContain(`session=${sessionPrefix}...`); expect(line).not.toMatch(/[\uD800-\uDFFF]/u); }); it("uses a canonical agent session key when only sessionId is available", async () => { hoisted.sessionStore["agent:main:telegram:direct:12345"] = { sessionId: "session-a", updatedAt: 25, delivery: { kind: "external", route: { channel: "telegram" }, context: { channel: "telegram" }, origin: { provider: "telegram" }, }, }; await runPromptBuild( { prompt: "what wings should i order? session id only" }, { sessionId: "session-a", }, ); expect(lastEmbeddedSessionKey()).toMatch( /^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/, ); expectEmbeddedChannel("telegram"); const entries = hoisted.sessionStore["agent:main:telegram:direct:12345"]?.pluginDebugEntries as | Array<{ pluginId?: string; lines?: string[] }> | undefined; expect(entries).toHaveLength(1); expect(entries?.[0]?.pluginId).toBe("active-memory"); expectLinesToContain(entries?.[0]?.lines ?? [], "🧩 Active Memory: status=ok"); }); it("uses the resolved canonical session key for non-webchat chat-type checks", async () => { seedSession("agent:main:telegram:direct:12345", "session-a", 25); const result = await runPromptBuild( { prompt: "what wings should i order? session id only telegram" }, { sessionId: "session-a", messageProvider: "telegram", channelId: "telegram", }, ); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); expect(lastEmbeddedSessionKey()).toMatch( /^agent:main:telegram:direct:12345:active-memory:[a-f0-9]{12}$/, ); expectPrependContextContains(result, "Context:"); }); it("surfaces memory embedding quota warnings in plugin trace lines", async () => { const sessionKey = "agent:main:memory-rate-limit"; seedSession(sessionKey, "s-rate-limit", 0); runEmbeddedAgent.mockImplementationOnce(async () => { return { meta: { activeMemorySearchDebug: { warning: "Memory search is unavailable because the embedding provider quota is exhausted.", action: "Top up or switch embedding provider, then retry memory_search.", error: "gemini embeddings failed: 429 rate limited", }, }, payloads: [{ text: "NONE" }], }; }); await runPromptBuild( { prompt: "what should i eat tonight?" }, { sessionKey, }, ); const entries = hoisted.sessionStore[sessionKey]?.pluginDebugEntries as | Array<{ pluginId?: string; lines?: string[] }> | undefined; expect(entries).toHaveLength(1); expect(entries?.[0]?.pluginId).toBe("active-memory"); const lines = entries?.[0]?.lines ?? []; expect(lines).toHaveLength(2); expectLinesToContain(lines, "🧩 Active Memory: status=unavailable"); expectLinesToContain( lines, "🔎 Active Memory Debug: Memory search is unavailable because the embedding provider quota is exhausted. Top up or switch embedding provider, then retry memory_search.", ); }); it("prefers the resolved session channel over a wrapper channel hint", async () => { hoisted.sessionStore["agent:main:telegram:direct:12345"] = { sessionId: "session-a", updatedAt: 25, delivery: { kind: "external", route: { channel: "telegram" }, context: { channel: "telegram" }, origin: { provider: "telegram" }, }, }; await runPromptBuild( { prompt: "what wings should i order? wrapper channel hint" }, { sessionKey: "agent:main:telegram:direct:12345", channelId: "webchat", }, ); expectEmbeddedChannel("telegram"); }); it("skips colon-containing session-store channels for embedded recall (#77396)", async () => { hoisted.sessionStore["agent:main:qqbot:direct:12345"] = { sessionId: "session-a", updatedAt: 25, delivery: { kind: "external", route: { channel: "qqbot" }, context: { channel: "c2c:10D4F7C2" }, origin: { provider: "qqbot" }, }, }; await runPromptBuild( { prompt: "what wings should i order? scoped stored channel" }, { sessionKey: "agent:main:qqbot:direct:12345", messageProvider: "qqbot", channelId: "qqbot", }, ); expectEmbeddedChannel("qqbot"); }); it("preserves an explicit real channel hint over a stale stored wrapper channel", async () => { hoisted.sessionStore["agent:main:telegram:direct:12345"] = { sessionId: "session-a", updatedAt: 25, delivery: { kind: "external", route: { channel: "webchat" }, context: {}, origin: { provider: "webchat" }, }, }; await runPromptBuild( { prompt: "what wings should i order? explicit channel hint" }, { sessionKey: "agent:main:telegram:direct:12345", channelId: "telegram", }, ); expectEmbeddedChannel("telegram"); }); it("preserves a direct explicit channel when weak legacy fallback disagrees", async () => { hoisted.sessionStore["agent:main:telegram:direct:12345"] = { sessionId: "session-a", updatedAt: 25, delivery: { kind: "external", route: { channel: "webchat" }, context: {}, origin: { provider: "webchat" }, }, }; await runPromptBuild( { prompt: "what wings should i order? direct explicit channel" }, { sessionKey: "agent:main:telegram:direct:12345", messageProvider: "telegram", channelId: "telegram", }, ); expectEmbeddedChannel("telegram"); }); it("clears stale status on skipped non-interactive turns even when agentId is missing", async () => { const sessionKey = "noncanonical-session"; hoisted.sessionStore[sessionKey] = { sessionId: "s-main", updatedAt: 0, pluginDebugEntries: [ { pluginId: "active-memory", lines: ["🧩 Active Memory: status=timeout elapsed=15s query=recent"], }, ], }; const result = await runPromptBuild( { prompt: "what wings should i order?" }, { trigger: "heartbeat", sessionKey, messageProvider: "webchat" }, ); expect(result).toBeUndefined(); const updater = lastSessionStoreUpdater(); const store = { [sessionKey]: { sessionId: "s-main", updatedAt: 0, pluginDebugEntries: [ { pluginId: "active-memory", lines: ["🧩 Active Memory: status=timeout elapsed=15s query=recent"], }, ], }, } as Record>; updater(store); expect(store[sessionKey]?.pluginDebugEntries).toBeUndefined(); }); it("supports message mode by sending only the latest user message", async () => { registerPluginConfig({ queryMode: "message" }); await runPromptBuild({ prompt: "what should i grab on the way?", messages: [ { role: "user", content: "i have a flight tomorrow" }, { role: "assistant", content: "got it" }, ], }); const prompt = lastEmbeddedPrompt(); expect(prompt).toContain("Bounded memory search query:\nwhat should i grab on the way?"); expect(prompt).toContain("Conversation context:\nwhat should i grab on the way?"); expect(prompt).not.toContain("Recent conversation tail:"); }); it("keeps recent conversation context UTF-16 well-formed", async () => { registerPluginConfig({ queryMode: "recent", recentUserTurns: 1, recentAssistantTurns: 1, recentUserChars: 40, recentAssistantChars: 40, }); await runPromptBuild({ prompt: "what now?", messages: [ { role: "user", content: `${"u".repeat(39)}🚀 user tail` }, { role: "assistant", content: `${"a".repeat(39)}🚀 assistant tail` }, ], }); const prompt = lastEmbeddedPrompt(); expect(prompt).toContain( [ "Conversation context:", "Recent conversation tail:", `user: ${"u".repeat(39)}`, `assistant: ${"a".repeat(39)}`, "", "Latest user message:", "what now?", ].join("\n"), ); expect(prompt).not.toMatch(UNPAIRED_SURROGATE_RE); }); it("keeps a whole code point when the bounded search query crosses an emoji", async () => { registerPluginConfig({ queryMode: "message" }); const prefix = "a".repeat(479); await runPromptBuild({ prompt: `${prefix}😀tail`, }); const query = lastEmbeddedPrompt().match(/Bounded memory search query:\n([^\n]*)/u)?.[1]; expect(query).toBe(prefix); }); it("sends a bounded latest-message query instead of channel metadata to memory search", async () => { registerPluginConfig({ queryMode: "recent" }); await runPromptBuild({ prompt: [ "Conversation info:", "Sender: discord:user-123", "Untrusted Discord message body", "---", "do you remember my flight preferences?", ].join("\n"), messages: [ { role: "user", content: "i have a flight tomorrow" }, { role: "assistant", content: "got it" }, ], }); const prompt = lastEmbeddedPrompt(); expect(prompt).toContain( "Bounded memory search query:\ndo you remember my flight preferences?", ); expect(prompt).toContain( "Do not use channel metadata, provider metadata, debug output, or the full conversation context as the memory tool query.", ); expect(prompt).toContain("Conversation context:"); expect(prompt).toContain("Conversation info:"); expect(prompt).not.toContain("Bounded memory search query:\nConversation info:"); expect(prompt).not.toContain("Bounded memory search query:\nSender:"); expect(prompt).not.toContain("Bounded memory search query:\nUntrusted Discord message body"); }); it("supports full mode by sending the whole conversation", async () => { registerPluginConfig({ queryMode: "full" }); await runPromptBuild({ prompt: "what should i grab on the way?", messages: [ { role: "user", content: "i have a flight tomorrow" }, { role: "assistant", content: "got it" }, { role: "user", content: "packing is annoying" }, ], }); const prompt = lastEmbeddedPrompt(); expect(prompt).toContain("Full conversation context:"); expect(prompt).toContain("user: i have a flight tomorrow"); expect(prompt).toContain("assistant: got it"); expect(prompt).toContain("user: packing is annoying"); }); it("strips prior memory/debug traces from assistant context before retrieval", async () => { registerPluginConfig({ queryMode: "recent" }); await runPromptBuild({ prompt: "what should i grab on the way?", messages: [ { role: "user", content: "i have a flight tomorrow" }, { role: "assistant", content: "🧠 Memory Search: favorite food comfort food tacos sushi ramen\n🧩 Active Memory: status=ok elapsed=842ms query=recent summary=2 mem\n🔎 Active Memory Debug: spicy ramen; tacos\nSounds like you want something easy before the airport.", }, ], }); const prompt = lastEmbeddedPrompt(); expect(prompt).toContain("Treat the latest user message as the primary query."); expect(prompt).toContain( "Use recent conversation only to disambiguate what the latest user message means.", ); expect(prompt).toContain( "Do not return memory just because it matched the broader recent topic; return memory only if it clearly helps with the latest user message itself.", ); expect(prompt).toContain( "If recent context and the latest user message point to different memory domains, prefer the domain that best matches the latest user message.", ); expect(prompt).toContain( "ignore that surfaced text unless the latest user message clearly requires re-checking it.", ); expect(prompt).toContain( "Latest user message: I might see a movie while I wait for the flight.", ); expect(prompt).toContain( "Return: User's favorite movie snack is buttery popcorn with extra salt.", ); expect(prompt).toContain("assistant: Sounds like you want something easy before the airport."); expect(prompt).not.toContain("Memory Search:"); expect(prompt).not.toContain("Active Memory:"); expect(prompt).not.toContain("Active Memory Debug:"); expect(prompt).not.toContain("spicy ramen; tacos"); }); it("strips prior active-memory prompt prefixes from user context before retrieval", async () => { registerPluginConfig({ queryMode: "recent" }); await runPromptBuild({ prompt: "what should i grab on the way?", messages: [ { role: "user", content: [ "Context:", "", "User prefers aisle seats and extra buffer on connections.", "", "", "i have a flight tomorrow", ].join("\n"), }, { role: "assistant", content: "got it" }, ], }); const prompt = lastEmbeddedPrompt(); expect(prompt).toContain("user: i have a flight tomorrow"); expect(prompt).not.toContain("Context:"); expect(prompt).not.toContain(""); expect(prompt).not.toContain("User prefers aisle seats and extra buffer on connections."); }); it("does not drop ordinary user text when the active-memory tag appears inline without a matching block", async () => { registerPluginConfig({ queryMode: "recent" }); await runPromptBuild({ prompt: "what should i grab on the way?", messages: [ { role: "user", content: "i literally typed in chat and still have a flight tomorrow", }, { role: "assistant", content: "got it" }, ], }); const prompt = lastEmbeddedPrompt(); expect(prompt).toContain( "user: i literally typed in chat and still have a flight tomorrow", ); }); it("does not drop ordinary user text that starts with active-memory-like prefixes", async () => { registerPluginConfig({ queryMode: "recent" }); await runPromptBuild({ prompt: "what should i remember?", messages: [ { role: "user", content: "Active Memory: I really do want you to remember that I prefer aisle seats.", }, { role: "user", content: "Memory Search: this is just me describing my own workflow in plain text.", }, { role: "assistant", content: "got it" }, ], }); const prompt = lastEmbeddedPrompt(); expect(prompt).toContain( "user: Active Memory: I really do want you to remember that I prefer aisle seats.", ); expect(prompt).toContain( "user: Memory Search: this is just me describing my own workflow in plain text.", ); }); it("trusts the subagent's relevance decision for explicit preference recall prompts", async () => { runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => { await writeUsableMemoryTranscript(params.sessionFile, "aisle seats and connection buffer"); return { payloads: [{ text: "User prefers aisle seats and extra buffer on connections." }], }; }); const result = await runPromptBuild({ prompt: "u remember my flight preferences" }); const prependContext = requirePrependContext(result); expect(prependContext).toContain("aisle seat"); expect(prependContext).toContain("extra buffer on connections"); }); it("applies total summary truncation after normalizing the subagent reply", async () => { registerPluginConfig({ maxSummaryChars: 40 }); const prependContext = await runRecallWithSummary({ prompt: "what wings should i order? word-boundary-truncation-40", summary: "alpha beta gamma delta epsilon zetalongword", memoryText: "alpha beta gamma", }); expect(prependContext).toContain("alpha beta gamma"); expect(prependContext).toContain("alpha beta gamma delta epsilon…"); expect(prependContext).not.toContain("zetalo"); expect(prependContext).not.toContain("zetalongword"); }); it.each([ { name: "split surrogate", summary: `${"a".repeat(38)}🎉TAILWORD`, expected: `${"a".repeat(38)}…`, }, { name: "whitespace before a split surrogate", summary: `alpha beta ${"c".repeat(26)} 🎉TAILWORD`, expected: `alpha beta ${"c".repeat(26)}…`, }, ])("keeps $name truncation UTF-16 safe", async ({ name, summary, expected }) => { registerPluginConfig({ maxSummaryChars: 40 }); const prependContext = await runRecallWithSummary({ prompt: `recall summary boundary: ${name}`, summary, memoryText: expected, }); expect(prependContext).toContain(expected); expect(prependContext).not.toContain("TAILWORD"); }); it("asks recall subagents to mark mutable operational facts stale unless source status is current", async () => { await runPromptBuild({ prompt: "is autonomous pickup running?" }); const prompt = lastEmbeddedPrompt(); expect(prompt).toContain("Mutable operational facts"); expect(prompt).toContain("source timestamp"); expect(prompt).toContain("verify live"); }); it("uses the configured maxSummaryChars value in the subagent prompt", async () => { registerPluginConfig({ maxSummaryChars: 90 }); await runPromptBuild( { prompt: "what wings should i order? prompt-count-check" }, { sessionKey: "agent:main:prompt-count-check", }, ); expect(lastEmbeddedPrompt()).toContain( "If something is useful, reply with one compact plain-text summary under 90 characters total.", ); }); it("keeps subagent transcripts off disk by default by using a temp session file", async () => { const mkdtempSpy = vi.spyOn(fs, "mkdtemp"); const rmSpy = vi.spyOn(fs, "rm"); await runPromptBuild({ prompt: "what wings should i order? temp transcript path" }); expect(mkdtempSpy).toHaveBeenCalled(); expect(rmSpy).toHaveBeenCalledWith(expect.stringMatching(/openclaw-active-memory-.*/), { recursive: true, force: true, }); }); it("persists subagent transcripts in a separate directory when enabled", async () => { registerPluginConfig({ persistTranscripts: true, transcriptDir: "active-memory-subagents", logging: true, }); const mkdirSpy = vi.spyOn(fs, "mkdir"); const mkdtempSpy = vi.spyOn(fs, "mkdtemp"); const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined); const sessionKey = "agent:main:persist-transcript"; await runPromptBuild( { prompt: "what wings should i order? persist transcript" }, { sessionKey }, ); const expectedDir = path.join( stateDir, "plugins", "active-memory", "transcripts", "agents", "main", "active-memory-subagents", ); expect(mkdirSpy).toHaveBeenCalledWith(expectedDir, { recursive: true, mode: 0o700 }); expect(mkdtempSpy).not.toHaveBeenCalled(); const transcriptPath = await expectSingleTranscriptArtifact(expectedDir); expect(await fs.readFile(transcriptPath, "utf8")).toContain("lemon pepper wings"); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(infoLines, `transcript=${expectedDir}${path.sep}`); expect(rmSpy.mock.calls.filter(([target]) => String(target).startsWith(expectedDir))).toEqual( [], ); }); it("falls back to the default transcript directory when transcriptDir is unsafe", async () => { registerPluginConfig({ persistTranscripts: true, transcriptDir: "C:/temp/escape", logging: true, }); const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined); await runPromptBuild( { prompt: "what wings should i order? unsafe transcript dir" }, { sessionKey: "agent:main:unsafe-transcript", }, ); const expectedDir = path.join( stateDir, "plugins", "active-memory", "transcripts", "agents", "main", "active-memory", ); expect(mkdirSpy).toHaveBeenCalledWith(expectedDir, { recursive: true, mode: 0o700 }); const runtimeSessionFile = requireNonEmptyString( lastRuntimeEmbeddedRunParams().sessionFile, "expected runtime session file", ); expect(parseSqliteSessionFileMarker(runtimeSessionFile)).toMatchObject({ agentId: "main", }); }); it("scopes persisted subagent transcripts by agent", async () => { registerPluginConfig({ agents: ["main", "support/agent"], persistTranscripts: true, transcriptDir: "active-memory-subagents", logging: true, }); const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined); await runPromptBuild( { prompt: "what wings should i order? support agent transcript" }, { agentId: "support/agent", sessionKey: "agent:support/agent:persist-transcript", }, ); const expectedDir = path.join( stateDir, "plugins", "active-memory", "transcripts", "agents", "support%2Fagent", "active-memory-subagents", ); expect(mkdirSpy).toHaveBeenCalledWith(expectedDir, { recursive: true, mode: 0o700 }); const runtimeSessionFile = requireNonEmptyString( lastRuntimeEmbeddedRunParams().sessionFile, "expected runtime session file", ); expect(parseSqliteSessionFileMarker(runtimeSessionFile)).toMatchObject({ agentId: "support/agent", }); }); it("sanitizes control characters out of debug lines", async () => { const sessionKey = "agent:main:debug-sanitize"; seedSession(sessionKey, "s-main", 0); runEmbeddedAgent.mockResolvedValueOnce({ payloads: [{ text: "- spicy ramen\u001b[31m\n- fries\r\n- blue cheese\t" }], }); await runPromptBuild({ prompt: "what should i order?" }, { sessionKey }); const updater = lastSessionStoreUpdater(); const store = { [sessionKey]: { sessionId: "s-main", updatedAt: 0, }, } as Record>; updater(store); const lines = (store[sessionKey]?.pluginDebugEntries as Array<{ lines?: string[] }> | undefined)?.[0] ?.lines ?? []; expectLinesNotToContain(lines, "\u001b"); expectLinesNotToContain(lines, "\r"); }); it("caps the active-memory cache size and evicts the oldest entries", () => { const sessionKey = "agent:main:cache-cap"; for (let index = 0; index <= 1000; index += 1) { testing.setCachedResult( testing.buildCacheKey({ agentId: "main", sessionKey, query: `cache pressure prompt ${index}`, }), { status: "ok", elapsedMs: 1, rawReply: `memory ${index}`, summary: `memory ${index}`, }, 15_000, ); } expect( testing.getCachedResult( testing.buildCacheKey({ agentId: "main", sessionKey, query: "cache pressure prompt 0", }), ), ).toBeUndefined(); const cached = testing.getCachedResult( testing.buildCacheKey({ agentId: "main", sessionKey, query: "cache pressure prompt 1", }), ); expect(cached?.status).toBe("ok"); expect(cached?.summary).toBe("memory 1"); }); it("drops cached active-memory results when the current clock is not a valid date timestamp", () => { const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); const cacheKey = testing.buildCacheKey({ agentId: "main", sessionKey: "agent:main:invalid-clock-cache", query: "cache invalid clock prompt", }); testing.setCachedResult( cacheKey, { status: "ok", elapsedMs: 1, rawReply: "memory", summary: "memory", }, 15_000, ); nowSpy.mockReturnValue(Number.NaN); expect(testing.getCachedResult(cacheKey)).toBeUndefined(); }); it("does not cache active-memory results when the expiry timestamp would exceed the valid date range", () => { vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000); const cacheKey = testing.buildCacheKey({ agentId: "main", sessionKey: "agent:main:overflow-cache", query: "cache overflow prompt", }); testing.setCachedResult( cacheKey, { status: "ok", elapsedMs: 1, rawReply: "memory", summary: "memory", }, 15_000, ); expect(testing.getCachedResult(cacheKey)).toBeUndefined(); }); it("skips recall after consecutive timeouts when circuit breaker trips (#74054)", async () => { const CONFIGURED_TIMEOUT_MS = 25; testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: CONFIGURED_TIMEOUT_MS, logging: true, circuitBreakerMaxTimeouts: 2, circuitBreakerCooldownMs: 60_000, }); runEmbeddedAgent.mockImplementation( async (params: { abortSignal?: AbortSignal }) => await waitForAbort(params.abortSignal), ); // First two calls should actually attempt the subagent (and timeout). await runPromptBuild( { prompt: "circuit breaker test 1" }, { sessionKey: "agent:main:cb-test", }, ); await runPromptBuild( { prompt: "circuit breaker test 2" }, { sessionKey: "agent:main:cb-test", }, ); expect(runEmbeddedAgent).toHaveBeenCalledTimes(2); // Third call should be skipped by the circuit breaker. await runPromptBuild( { prompt: "circuit breaker test 3" }, { sessionKey: "agent:main:cb-test", }, ); // The subagent should NOT have been called a third time. expect(runEmbeddedAgent).toHaveBeenCalledTimes(2); const infoLines = vi .mocked(api.logger.info) .mock.calls.map((call: unknown[]) => String(call[0])); expectLinesToContain(infoLines, "circuit breaker open"); }); it("resets circuit breaker after a successful recall", async () => { const CONFIGURED_TIMEOUT_MS = 25; testing.setMinimumTimeoutMsForTests(1); testing.setSetupGraceTimeoutMsForTests(0); registerPluginConfig({ timeoutMs: CONFIGURED_TIMEOUT_MS, logging: true, circuitBreakerMaxTimeouts: 1, circuitBreakerCooldownMs: 60_000, }); // First call: timeout (trips the breaker with max=1). runEmbeddedAgent.mockImplementationOnce( async (params: { abortSignal?: AbortSignal }) => await waitForAbort(params.abortSignal), ); await runPromptBuild( { prompt: "cb reset test timeout" }, { sessionKey: "agent:main:cb-reset", }, ); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); // Second call should be skipped by circuit breaker. await runPromptBuild( { prompt: "cb reset test skipped" }, { sessionKey: "agent:main:cb-reset", }, ); expect(runEmbeddedAgent).toHaveBeenCalledTimes(1); // Simulate cooldown expiry by manipulating the circuit breaker entry. const cbKey = testing.buildCircuitBreakerKey("main", "github-copilot", "gpt-5.4-mini"); const entry = testing.getCircuitBreakerEntry(cbKey); if (entry) { entry.lastTimeoutAt = Date.now() - 120_000; } // Third call should go through (cooldown expired) and succeed. runEmbeddedAgent.mockImplementationOnce(async () => ({ payloads: [{ text: "- lemon pepper wings" }], })); await runPromptBuild( { prompt: "cb reset test success" }, { sessionKey: "agent:main:cb-reset", }, ); expect(runEmbeddedAgent).toHaveBeenCalledTimes(2); // Fourth call should also go through since the breaker was reset on success. runEmbeddedAgent.mockImplementationOnce(async () => ({ payloads: [{ text: "- buffalo wings" }], })); await runPromptBuild( { prompt: "cb reset test still ok" }, { sessionKey: "agent:main:cb-reset", }, ); expect(runEmbeddedAgent).toHaveBeenCalledTimes(3); }); it("normalizes circuit breaker config with defaults", () => { const config = testing.normalizePluginConfig({}); expect(config.circuitBreakerMaxTimeouts).toBe(3); expect(config.circuitBreakerCooldownMs).toBe(60_000); }); it("normalizes explicit fast-mode overrides and ignores invalid values", () => { expect(testing.normalizePluginConfig({}).fastMode).toBeUndefined(); expect(testing.normalizePluginConfig({ fastMode: true }).fastMode).toBe(true); expect(testing.normalizePluginConfig({ fastMode: false }).fastMode).toBe(false); expect(testing.normalizePluginConfig({ fastMode: "auto" }).fastMode).toBe("auto"); expect(testing.normalizePluginConfig({ fastMode: "on" }).fastMode).toBeUndefined(); }); it("raises the default recall budget only when CLI dispatch is eligible", () => { const defaults = testing.normalizePluginConfig({}); expect(defaults.timeoutMs).toBe(15_000); expect(defaults.timeoutMsIsDefault).toBe(true); expect(applyCliRuntimeRecallTimeoutDefault(defaults, true).timeoutMs).toBe(45_000); expect(applyCliRuntimeRecallTimeoutDefault(defaults, false).timeoutMs).toBe(15_000); // Explicit operator config always wins. const explicit = testing.normalizePluginConfig({ timeoutMs: 20_000 }); expect(explicit.timeoutMsIsDefault).toBe(false); expect(applyCliRuntimeRecallTimeoutDefault(explicit, true).timeoutMs).toBe(20_000); }); it("applies the CLI dispatch recall budget to the embedded run", async () => { registerPluginConfig({ agents: ["main"], logging: true }); resolveCliBackendDispatchEligibility.mockReturnValueOnce({ provider: "claude-cli" }); runEmbeddedAgent.mockImplementationOnce(async () => ({ payloads: [{ text: "- lemon pepper wings" }], })); await runPromptBuild( { prompt: "what wings should i order?" }, { modelProviderId: "claude-cli", modelId: "claude-opus-4-8", }, ); // 45s CLI-dispatch default + 0ms setup grace. expect(lastEmbeddedRunParams().timeoutMs).toBe(45_000); // Budgeting consults the runner's own eligibility decision. expect(resolveCliBackendDispatchEligibility).toHaveBeenCalledWith( expect.objectContaining({ provider: "claude-cli", model: "claude-opus-4-8" }), ); }); it("keeps the plain recall budget when CLI dispatch is not eligible", async () => { // API-key and missing-backend routes resolve to no eligibility: the run // stays on the direct passthrough, so the plain 15s default applies. registerPluginConfig({ agents: ["main"], logging: true }); resolveCliBackendDispatchEligibility.mockReturnValueOnce(undefined); runEmbeddedAgent.mockImplementationOnce(async () => ({ payloads: [{ text: "- lemon pepper wings" }], })); await runPromptBuild( { prompt: "what wings should i order?" }, { modelProviderId: "claude-cli", modelId: "claude-opus-4-8", }, ); expect(lastEmbeddedRunParams().timeoutMs).toBe(15_000); }); it("normalizes setup grace config with a zero default and bounded opt-in", () => { expect(testing.normalizePluginConfig({}).setupGraceTimeoutMs).toBe(0); expect(testing.normalizePluginConfig({ setupGraceTimeoutMs: 30_001 }).setupGraceTimeoutMs).toBe( 30_000, ); expect(testing.normalizePluginConfig({ setupGraceTimeoutMs: -1 }).setupGraceTimeoutMs).toBe(0); }); it("clamps circuit breaker config within valid ranges", () => { const config = testing.normalizePluginConfig({ circuitBreakerMaxTimeouts: 0, circuitBreakerCooldownMs: 1000, }); expect(config.circuitBreakerMaxTimeouts).toBe(1); expect(config.circuitBreakerCooldownMs).toBe(5000); }); }); function toLintErrorObject(value: unknown, fallbackMessage: string): Error { if (value instanceof Error) { return value; } if (typeof value === "string") { return new Error(value); } const error = new Error(fallbackMessage, { cause: value }); if ((typeof value === "object" && value !== null) || typeof value === "function") { Object.assign(error, value); } return error; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */