Files
openclaw/extensions/active-memory/index.test.ts
Qiong a566ac23a0 fix(active-memory): detect Chinese recall intent in escalate mode (#117338) (#117419)
* fix(active-memory): detect Chinese recall intent in escalate mode (#117338)

* fix(active-memory): recognize formatted multilingual recall intent

---------

Co-authored-by: Peter Steinberger <steipete@macos.shared>
2026-08-01 09:21:45 -07:00

5936 lines
204 KiB
TypeScript

// 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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
async function expectPathMissing(targetPath: string): Promise<void> {
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<string> {
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<string, Record<string, unknown>> = {
"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<string, string>,
sessionStore,
updateSessionStore: vi.fn(
async (
_storePath: string,
updater: (store: Record<string, Record<string, unknown>>) => 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<typeof import("openclaw/plugin-sdk/session-store-runtime")>(
"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<typeof actual.readSessionTranscriptRawDelta>[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<typeof actual.readSessionTranscriptEvents>[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<string, Function> = {};
const requireHook = (name: string): Function =>
expectDefined(hooks[name], `active-memory ${name} hook registration`);
const hookOptions: Record<string, Record<string, unknown> | undefined> = {};
const registeredCommands: Record<string, any> = {};
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<string, unknown>) => {
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<string, unknown>;
const meta =
typeof resultRecord.meta === "object" &&
resultRecord.meta !== null &&
!Array.isArray(resultRecord.meta)
? (resultRecord.meta as Record<string, unknown>)
: {};
const agentMeta =
typeof meta.agentMeta === "object" &&
meta.agentMeta !== null &&
!Array.isArray(meta.agentMeta)
? (meta.agentMeta as Record<string, unknown>)
: {};
return {
...resultRecord,
meta: {
...meta,
agentMeta: {
sessionFile: testSessionFile,
...agentMeta,
},
},
};
});
let fixtureRoot = "";
let pluginStateDir = "";
let stateDir = "";
let configFile: Record<string, unknown> = {};
let pluginConfig: Record<string, unknown> = {
agents: ["main"],
mode: "always",
logging: true,
};
let apiConfig: Record<string, unknown> = {};
const syncRuntimePluginConfig = (nextPluginConfig: Record<string, unknown>) => {
pluginConfig = { mode: "always", ...nextPluginConfig };
const plugins = configFile.plugins as Record<string, unknown> | undefined;
const entries = plugins?.entries as Record<string, unknown> | undefined;
const existingEntry = entries?.["active-memory"] as Record<string, unknown> | undefined;
configFile = {
...configFile,
plugins: {
...plugins,
entries: {
...entries,
"active-memory": {
...existingEntry,
enabled: true,
config: pluginConfig,
},
},
},
};
};
const setMemorySlot = (memory: string) => {
const plugins = configFile.plugins as Record<string, unknown> | undefined;
configFile = {
...configFile,
plugins: {
...plugins,
slots: {
...(plugins?.slots as Record<string, unknown> | undefined),
memory,
},
},
};
};
const api: any = {
get pluginConfig() {
return pluginConfig;
},
set pluginConfig(nextPluginConfig: Record<string, unknown>) {
syncRuntimePluginConfig(nextPluginConfig);
},
get config() {
return apiConfig;
},
set config(nextConfig: Record<string, unknown>) {
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<string, unknown> }) => {
hoisted.sessionStore[params.sessionKey] = { ...params.entry };
},
),
patchSessionEntry: vi.fn(
async (params: {
sessionKey: string;
fallbackEntry?: Record<string, unknown>;
update: (entry: Record<string, unknown>) => Record<string, unknown> | null;
}) => {
let result: Record<string, unknown> | null = null;
await hoisted.updateSessionStore(
path.join(stateDir, "sessions.json"),
(store: Record<string, Record<string, unknown>>) => {
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<string, unknown>) => void }) => {
const draft = structuredClone(configFile);
mutate(draft);
configFile = draft;
return { changed: true, config: configFile };
},
),
replaceConfigFile: vi.fn(
async ({ nextConfig }: { nextConfig: Record<string, unknown> }) => {
configFile = nextConfig;
},
),
writeConfigFile: vi.fn(async (nextConfig: Record<string, unknown>) => {
configFile = nextConfig;
}),
},
},
registerCommand: vi.fn((command) => {
registeredCommands[command.name] = command;
}),
on: vi.fn((hookName: string, handler: Function, opts?: Record<string, unknown>) => {
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<never> => {
if (abortSignal?.aborted) {
throw toLintErrorObject(
(abortSignal.reason as unknown) ?? new Error("Operation aborted"),
"Non-Error thrown",
);
}
return await new Promise<never>((_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<string, unknown> => {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(message);
}
return value as Record<string, unknown>;
};
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<string> => {
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<string, Record<string, unknown>>) => 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<string, unknown>) => {
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<string, unknown>?]>;
if (!call) {
throw new Error("expected before_prompt_build hook registration");
}
return call;
};
const runPromptBuild = (
event: Record<string, unknown>,
context: Record<string, unknown> = {},
) => {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>;
replaceEntry?: boolean;
sessionKey: string;
skipMaintenance?: boolean;
update: (
entry: Record<string, unknown>,
context: { existingEntry?: Record<string, unknown> },
) => Record<string, unknown> | 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<void>((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<void>((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<string, unknown> | undefined;
runEmbeddedAgent.mockImplementationOnce(async (params: Record<string, unknown>) => {
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<void>((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<void>((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<void>((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<void>((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<void>((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<string, unknown>),
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:<id>:direct:<peer>)",
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:<id>:<channel>:<account>:direct:<peer>)",
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:<id> 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:<id> 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" ? "<active_memory_plugin>" : "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<typeof vi.fn>).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<string, Record<string, unknown>>;
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<string, Record<string, unknown>>;
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<string, unknown>;
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<string, Record<string, unknown>>;
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("<active_memory_plugin>");
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(
"<active_memory_plugin>\nUser prefers aisle seats.\n</active_memory_plugin>",
);
expect(testing.buildPromptPrefix(summary)).toBe(
"Context:\n<active_memory_plugin>\nUser prefers aisle seats.\n</active_memory_plugin>",
);
});
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<never>(() => {}));
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<never>(() => {}));
hoisted.updateSessionStore.mockImplementationOnce(
async () =>
await new Promise<void>((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<void>((resolve) => {
markPersistenceStarted = resolve;
});
hoisted.updateSessionStore.mockImplementationOnce(async () => {
markPersistenceStarted?.();
await new Promise<void>((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<never>(() => {}));
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<void>((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<void>((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<void>((resolve) => {
resolveLateWrite = resolve;
});
let releaseLateWrite: () => void = () => {};
const lateWriteRelease = new Promise<void>((resolve) => {
releaseLateWrite = resolve;
});
runEmbeddedAgent.mockImplementationOnce(
async (params: { sessionFile: string; abortSignal?: AbortSignal }) => {
await new Promise<void>((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<void>((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<undefined>((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<undefined>((resolve) => {
setTimeout(() => resolve(undefined), 1_490);
}),
});
plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedAgent.mockImplementationOnce(() => new Promise<never>(() => {}));
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<void>((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<string, Record<string, unknown>>;
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:",
"<active_memory_plugin>",
"User prefers aisle seats and extra buffer on connections.",
"</active_memory_plugin>",
"",
"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("<active_memory_plugin>");
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 <active_memory_plugin> in chat and still have a flight tomorrow",
},
{ role: "assistant", content: "got it" },
],
});
const prompt = lastEmbeddedPrompt();
expect(prompt).toContain(
"user: i literally typed <active_memory_plugin> 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<string, Record<string, unknown>>;
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. */