test: seed active memory state through sqlite

This commit is contained in:
Peter Steinberger
2026-05-08 16:09:26 +01:00
parent a4666a1de2
commit 1a5314ee29
11 changed files with 270 additions and 144 deletions

View File

@@ -48,6 +48,7 @@ function createDeps(overrides?: Partial<QaScenarioRuntimeDeps>): QaScenarioRunti
readEffectiveTools: fn,
readSkillStatus: fn,
readRawQaSessionEntries: fn,
setQaActiveMemorySessionDisabled: fn,
seedQaSessionTranscript: fn,
runQaCli: fn,
extractMediaPathFromText: fn,

View File

@@ -60,6 +60,7 @@ export type QaScenarioRuntimeDeps = {
readEffectiveTools: QaScenarioRuntimeFunction;
readSkillStatus: QaScenarioRuntimeFunction;
readRawQaSessionEntries: QaScenarioRuntimeFunction;
setQaActiveMemorySessionDisabled: QaScenarioRuntimeFunction;
seedQaSessionTranscript: QaScenarioRuntimeFunction;
runQaCli: QaScenarioRuntimeFunction;
extractMediaPathFromText: QaScenarioRuntimeFunction;
@@ -145,6 +146,7 @@ type QaScenarioRuntimeApi<
readEffectiveTools: TDeps["readEffectiveTools"];
readSkillStatus: TDeps["readSkillStatus"];
readRawQaSessionEntries: TDeps["readRawQaSessionEntries"];
setQaActiveMemorySessionDisabled: TDeps["setQaActiveMemorySessionDisabled"];
seedQaSessionTranscript: TDeps["seedQaSessionTranscript"];
runQaCli: TDeps["runQaCli"];
extractMediaPathFromText: TDeps["extractMediaPathFromText"];
@@ -245,6 +247,7 @@ export function createQaScenarioRuntimeApi<
readEffectiveTools: params.deps.readEffectiveTools,
readSkillStatus: params.deps.readSkillStatus,
readRawQaSessionEntries: params.deps.readRawQaSessionEntries,
setQaActiveMemorySessionDisabled: params.deps.setQaActiveMemorySessionDisabled,
seedQaSessionTranscript: params.deps.seedQaSessionTranscript,
runQaCli: params.deps.runQaCli,
extractMediaPathFromText: params.deps.extractMediaPathFromText,

View File

@@ -4,6 +4,7 @@ import {
replaceSqliteSessionTranscriptEvents,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { upsertSessionEntry } from "openclaw/plugin-sdk/config-runtime";
import { createPluginStateKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { liveTurnTimeoutMs } from "./suite-runtime-agent-common.js";
import type {
QaRawSessionEntry,
@@ -11,6 +12,20 @@ import type {
QaSuiteRuntimeEnv,
} from "./suite-runtime-types.js";
type ActiveMemorySessionToggleEntry = {
version: 1;
disabled: true;
updatedAt: number;
};
function createActiveMemorySessionToggleStore(env: Pick<QaSuiteRuntimeEnv, "gateway">) {
return createPluginStateKeyedStore<ActiveMemorySessionToggleEntry>("active-memory", {
namespace: "session-toggles",
maxEntries: 50_000,
env: env.gateway.runtimeEnv,
});
}
async function createSession(
env: Pick<QaSuiteRuntimeEnv, "gateway" | "primaryModel" | "alternateModel" | "providerMode">,
label: string,
@@ -120,6 +135,27 @@ async function seedQaSessionTranscript(
return { agentId, sessionId, sessionKey, sessionFile };
}
async function setQaActiveMemorySessionDisabled(
env: Pick<QaSuiteRuntimeEnv, "gateway">,
params: { sessionKey: string; disabled: boolean; now?: number },
) {
const sessionKey = params.sessionKey.trim();
if (!sessionKey) {
throw new Error("setQaActiveMemorySessionDisabled requires sessionKey");
}
const toggleStore = createActiveMemorySessionToggleStore(env);
if (params.disabled) {
await toggleStore.register(sessionKey, {
version: 1,
disabled: true,
updatedAt: params.now ?? Date.now(),
});
return { sessionKey, disabled: true };
}
await toggleStore.delete(sessionKey);
return { sessionKey, disabled: false };
}
async function readEffectiveTools(
env: Pick<QaSuiteRuntimeEnv, "gateway" | "primaryModel" | "alternateModel" | "providerMode">,
sessionKey: string,
@@ -213,5 +249,6 @@ export {
readEffectiveTools,
readRawQaSessionEntries,
readSkillStatus,
setQaActiveMemorySessionDisabled,
seedQaSessionTranscript,
};

View File

@@ -3,6 +3,7 @@ export {
readEffectiveTools,
readRawQaSessionEntries,
readSkillStatus,
setQaActiveMemorySessionDisabled,
seedQaSessionTranscript,
} from "./suite-runtime-agent-session.js";
export {

View File

@@ -22,6 +22,7 @@ const createSession = vi.hoisted(() => vi.fn());
const readEffectiveTools = vi.hoisted(() => vi.fn());
const readSkillStatus = vi.hoisted(() => vi.fn());
const readRawQaSessionEntries = vi.hoisted(() => vi.fn());
const setQaActiveMemorySessionDisabled = vi.hoisted(() => vi.fn());
const seedQaSessionTranscript = vi.hoisted(() => vi.fn());
const runQaCli = vi.hoisted(() => vi.fn());
const extractMediaPathFromText = vi.hoisted(() => vi.fn());
@@ -88,6 +89,7 @@ vi.mock("./suite-runtime-agent.js", () => ({
readEffectiveTools,
readSkillStatus,
readRawQaSessionEntries,
setQaActiveMemorySessionDisabled,
seedQaSessionTranscript,
runQaCli,
extractMediaPathFromText,

View File

@@ -40,6 +40,7 @@ import {
resolveGeneratedImagePath,
runAgentPrompt,
runQaCli,
setQaActiveMemorySessionDisabled,
seedQaSessionTranscript,
startAgentRun,
waitForAgentRun,
@@ -163,6 +164,7 @@ function createQaSuiteScenarioDeps(params: QaSuiteScenarioDepsParams) {
readEffectiveTools,
readSkillStatus,
readRawQaSessionEntries,
setQaActiveMemorySessionDisabled,
seedQaSessionTranscript,
runQaCli,
extractMediaPathFromText,

View File

@@ -85,30 +85,12 @@ steps:
- set: activeSessionKey
value:
expr: "'agent:qa:qa-channel:direct:active-memory-on'"
- set: transcriptRoot
value:
expr: "path.join(env.gateway.tempRoot, 'state', 'plugins', 'active-memory', 'transcripts', 'agents', 'qa', config.transcriptDir)"
- set: toggleStorePath
value:
expr: "path.join(env.gateway.tempRoot, 'state', 'plugins', 'active-memory', 'session-toggles.json')"
- call: fs.rm
- call: setQaActiveMemorySessionDisabled
args:
- ref: transcriptRoot
- recursive: true
force: true
- call: fs.rm
args:
- ref: toggleStorePath
- force: true
- call: fs.mkdir
args:
- expr: "path.dirname(toggleStorePath)"
- recursive: true
- call: fs.writeFile
args:
- ref: toggleStorePath
- expr: "`${JSON.stringify({ sessions: { [baselineSessionKey]: { disabled: true, updatedAt: Date.now() } } }, null, 2)}\\n`"
- utf8
- ref: env
- sessionKey:
ref: baselineSessionKey
disabled: true
- set: requestCountBeforeBaseline
value:
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
@@ -152,11 +134,12 @@ steps:
- set: requestCountBeforeActive
value:
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
- call: fs.writeFile
- call: setQaActiveMemorySessionDisabled
args:
- ref: toggleStorePath
- expr: "'{}\\n'"
- utf8
- ref: env
- sessionKey:
ref: activeSessionKey
disabled: false
- set: activeStartIndex
value:
expr: "state.getSnapshot().messages.length"
@@ -189,24 +172,6 @@ steps:
expr: "activeLower.includes(normalizeLowercaseStringOrEmpty(config.expectedNeedle))"
message:
expr: "`active memory reply missed the hidden preference: ${activeOutbound.text}`"
- call: waitForCondition
saveAs: transcriptPath
args:
- lambda:
async: true
expr: "await (async () => { const entries = (await fs.readdir(transcriptRoot).catch(() => [])).filter((entry) => entry.endsWith('.jsonl')).toSorted(); return entries.length > 0 ? path.join(transcriptRoot, entries.at(-1)) : undefined; })()"
- 10000
- call: fs.readFile
saveAs: transcriptText
args:
- ref: transcriptPath
- utf8
- assert:
expr: "transcriptText.includes('memory_search')"
message: active memory transcript missing memory_search
- assert:
expr: "transcriptText.includes('memory_get')"
message: active memory transcript missing memory_get
- call: waitForCondition
saveAs: activeSessionEntry
args:
@@ -226,5 +191,5 @@ steps:
- assert:
expr: "mockRequests.some((request) => request.allInputText.includes('You are a memory search agent.') && request.plannedToolName === 'memory_get')"
message: expected mock Active Memory memory_get request
detailsExpr: "`${activeOutbound.text}\\n\\ntranscript=${transcriptPath}`"
detailsExpr: "`${activeOutbound.text}\\n\\nactiveSession=${JSON.stringify(activeSessionEntry)}`"
```

View File

@@ -9,6 +9,7 @@ import { requireNodeSqlite } from "../infra/node-sqlite.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
type OpenClawStateDatabaseOptions,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { resolvePluginStateSqlitePath } from "./plugin-state-store.paths.js";
@@ -293,8 +294,10 @@ function sweepExpiredPluginStateEntriesFromDatabase(db: DatabaseSync, now: numbe
function openPluginStateDatabase(
operation: PluginStateStoreOperation = "open",
options: OpenClawStateDatabaseOptions = {},
): PluginStateDatabase {
const pathname = resolvePluginStateSqlitePath(process.env);
const env = options.env ?? process.env;
const pathname = resolvePluginStateSqlitePath(env);
if (cachedDatabase && cachedDatabase.path === pathname && cachedDatabase.db.isOpen) {
return cachedDatabase;
}
@@ -303,7 +306,7 @@ function openPluginStateDatabase(
}
try {
const database = openOpenClawStateDatabase();
const database = openOpenClawStateDatabase(options);
cachedDatabase = {
db: database.db,
path: database.path,
@@ -335,15 +338,20 @@ function countRow(row: CountRow | undefined): number {
return typeof raw === "bigint" ? Number(raw) : raw;
}
function envOptions(env?: NodeJS.ProcessEnv): OpenClawStateDatabaseOptions {
return env ? { env } : {};
}
function runWriteTransaction<T>(
operation: PluginStateStoreOperation,
write: (store: PluginStateDatabase) => T,
options: OpenClawStateDatabaseOptions = {},
): T {
return runOpenClawStateWriteTransaction(() => {
const store = openPluginStateDatabase(operation);
const store = openPluginStateDatabase(operation, options);
const result = write(store);
return result;
});
}, options);
}
function enforcePostRegisterLimits(params: {
@@ -377,36 +385,41 @@ export function pluginStateRegister(params: {
valueJson: string;
maxEntries: number;
ttlMs?: number;
env?: NodeJS.ProcessEnv;
}): void {
try {
runWriteTransaction("register", (store) => {
const now = Date.now();
const expiresAt = params.ttlMs == null ? null : now + params.ttlMs;
deleteExpiredPluginStateNamespaceEntries(store.db, {
pluginId: params.pluginId,
namespace: params.namespace,
now,
});
upsertPluginStateEntry(
store.db,
bindPluginStateEntry({
runWriteTransaction(
"register",
(store) => {
const now = Date.now();
const expiresAt = params.ttlMs == null ? null : now + params.ttlMs;
deleteExpiredPluginStateNamespaceEntries(store.db, {
pluginId: params.pluginId,
namespace: params.namespace,
key: params.key,
valueJson: params.valueJson,
createdAt: now,
expiresAt,
}),
);
enforcePostRegisterLimits({
store,
pluginId: params.pluginId,
namespace: params.namespace,
maxEntries: params.maxEntries,
now,
protectedKey: params.key,
});
});
now,
});
upsertPluginStateEntry(
store.db,
bindPluginStateEntry({
pluginId: params.pluginId,
namespace: params.namespace,
key: params.key,
valueJson: params.valueJson,
createdAt: now,
expiresAt,
}),
);
enforcePostRegisterLimits({
store,
pluginId: params.pluginId,
namespace: params.namespace,
maxEntries: params.maxEntries,
now,
protectedKey: params.key,
});
},
envOptions(params.env),
);
} catch (error) {
throw wrapPluginStateError(
error,
@@ -424,40 +437,45 @@ export function pluginStateRegisterIfAbsent(params: {
valueJson: string;
maxEntries: number;
ttlMs?: number;
env?: NodeJS.ProcessEnv;
}): boolean {
try {
return runWriteTransaction("register", (store) => {
const now = Date.now();
const expiresAt = params.ttlMs == null ? null : now + params.ttlMs;
deleteExpiredPluginStateNamespaceEntries(store.db, {
pluginId: params.pluginId,
namespace: params.namespace,
now,
});
const inserted = insertPluginStateEntryIfAbsent(
store.db,
bindPluginStateEntry({
return runWriteTransaction(
"register",
(store) => {
const now = Date.now();
const expiresAt = params.ttlMs == null ? null : now + params.ttlMs;
deleteExpiredPluginStateNamespaceEntries(store.db, {
pluginId: params.pluginId,
namespace: params.namespace,
key: params.key,
valueJson: params.valueJson,
createdAt: now,
expiresAt,
}),
);
if (!inserted) {
return false;
}
enforcePostRegisterLimits({
store,
pluginId: params.pluginId,
namespace: params.namespace,
maxEntries: params.maxEntries,
now,
protectedKey: params.key,
});
return true;
});
now,
});
const inserted = insertPluginStateEntryIfAbsent(
store.db,
bindPluginStateEntry({
pluginId: params.pluginId,
namespace: params.namespace,
key: params.key,
valueJson: params.valueJson,
createdAt: now,
expiresAt,
}),
);
if (!inserted) {
return false;
}
enforcePostRegisterLimits({
store,
pluginId: params.pluginId,
namespace: params.namespace,
maxEntries: params.maxEntries,
now,
protectedKey: params.key,
});
return true;
},
envOptions(params.env),
);
} catch (error) {
throw wrapPluginStateError(
error,
@@ -472,9 +490,10 @@ export function pluginStateLookup(params: {
pluginId: string;
namespace: string;
key: string;
env?: NodeJS.ProcessEnv;
}): unknown {
try {
const { db } = openPluginStateDatabase("lookup");
const { db } = openPluginStateDatabase("lookup", envOptions(params.env));
const row = selectPluginStateEntry(db, {
pluginId: params.pluginId,
namespace: params.namespace,
@@ -496,21 +515,26 @@ export function pluginStateConsume(params: {
pluginId: string;
namespace: string;
key: string;
env?: NodeJS.ProcessEnv;
}): unknown {
try {
return runWriteTransaction("consume", (store) => {
const row = selectPluginStateEntry(store.db, {
pluginId: params.pluginId,
namespace: params.namespace,
key: params.key,
now: Date.now(),
});
if (!row) {
return undefined;
}
deletePluginStateEntry(store.db, params);
return parseStoredJson(row.value_json, "consume");
});
return runWriteTransaction(
"consume",
(store) => {
const row = selectPluginStateEntry(store.db, {
pluginId: params.pluginId,
namespace: params.namespace,
key: params.key,
now: Date.now(),
});
if (!row) {
return undefined;
}
deletePluginStateEntry(store.db, params);
return parseStoredJson(row.value_json, "consume");
},
envOptions(params.env),
);
} catch (error) {
throw wrapPluginStateError(
error,
@@ -525,11 +549,16 @@ export function pluginStateDelete(params: {
pluginId: string;
namespace: string;
key: string;
env?: NodeJS.ProcessEnv;
}): boolean {
try {
return runWriteTransaction("delete", ({ db }) => {
return deletePluginStateEntry(db, params) > 0;
});
return runWriteTransaction(
"delete",
({ db }) => {
return deletePluginStateEntry(db, params) > 0;
},
envOptions(params.env),
);
} catch (error) {
throw wrapPluginStateError(
error,
@@ -543,9 +572,10 @@ export function pluginStateDelete(params: {
export function pluginStateEntries(params: {
pluginId: string;
namespace: string;
env?: NodeJS.ProcessEnv;
}): PluginStateEntry<unknown>[] {
try {
const { db } = openPluginStateDatabase("entries");
const { db } = openPluginStateDatabase("entries", envOptions(params.env));
const rows = selectPluginStateEntries(db, {
pluginId: params.pluginId,
namespace: params.namespace,
@@ -562,17 +592,25 @@ export function pluginStateEntries(params: {
}
}
export function pluginStateClear(params: { pluginId: string; namespace: string }): void {
export function pluginStateClear(params: {
pluginId: string;
namespace: string;
env?: NodeJS.ProcessEnv;
}): void {
try {
runWriteTransaction("clear", ({ db }) => {
executeSqliteQuerySync(
db,
getPluginStateKysely(db)
.deleteFrom("plugin_state_entries")
.where("plugin_id", "=", params.pluginId)
.where("namespace", "=", params.namespace),
);
});
runWriteTransaction(
"clear",
({ db }) => {
executeSqliteQuerySync(
db,
getPluginStateKysely(db)
.deleteFrom("plugin_state_entries")
.where("plugin_id", "=", params.pluginId)
.where("namespace", "=", params.namespace),
);
},
envOptions(params.env),
);
} catch (error) {
throw wrapPluginStateError(
error,

View File

@@ -79,6 +79,38 @@ describe("plugin state keyed store", () => {
});
});
it("honors explicit store env without mutating process state", async () => {
await withOpenClawTestState(
{ label: "plugin-state-explicit-env-a", applyEnv: false },
async (stateA) => {
await withOpenClawTestState(
{ label: "plugin-state-explicit-env-b", applyEnv: false },
async (stateB) => {
const storeA = createPluginStateKeyedStore<{ owner: string }>("discord", {
namespace: "explicit-env",
maxEntries: 10,
env: stateA.env,
});
const storeB = createPluginStateKeyedStore<{ owner: string }>("discord", {
namespace: "explicit-env",
maxEntries: 10,
env: stateB.env,
});
await storeA.register("shared", { owner: "a" });
await storeB.register("shared", { owner: "b" });
await expect(storeA.lookup("shared")).resolves.toEqual({ owner: "a" });
await expect(storeB.lookup("shared")).resolves.toEqual({ owner: "b" });
expect(resolvePluginStateSqlitePath(stateA.env)).not.toBe(
resolvePluginStateSqlitePath(stateB.env),
);
},
);
},
);
});
it("upserts values and refreshes deterministic entry ordering", async () => {
await withPluginStateTestState(async () => {
vi.useFakeTimers();

View File

@@ -244,6 +244,7 @@ function createKeyedStoreForPluginId<T>(
const namespace = validateNamespace(options.namespace);
const maxEntries = validateMaxEntries(options.maxEntries);
const defaultTtlMs = validateOptionalTtlMs(options.defaultTtlMs);
const env = options.env;
assertConsistentOptions(pluginId, namespace, { maxEntries, defaultTtlMs });
return {
@@ -255,6 +256,7 @@ function createKeyedStoreForPluginId<T>(
key: params.key,
valueJson: params.valueJson,
maxEntries,
...(env ? { env } : {}),
...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}),
});
},
@@ -266,26 +268,46 @@ function createKeyedStoreForPluginId<T>(
key: params.key,
valueJson: params.valueJson,
maxEntries,
...(env ? { env } : {}),
...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}),
});
},
async lookup(key) {
const normalizedKey = validateKey(key, "lookup");
return pluginStateLookup({ pluginId, namespace, key: normalizedKey }) as T | undefined;
return pluginStateLookup({
pluginId,
namespace,
key: normalizedKey,
...(env ? { env } : {}),
}) as T | undefined;
},
async consume(key) {
const normalizedKey = validateKey(key, "consume");
return pluginStateConsume({ pluginId, namespace, key: normalizedKey }) as T | undefined;
return pluginStateConsume({
pluginId,
namespace,
key: normalizedKey,
...(env ? { env } : {}),
}) as T | undefined;
},
async delete(key) {
const normalizedKey = validateKey(key, "delete");
return pluginStateDelete({ pluginId, namespace, key: normalizedKey });
return pluginStateDelete({
pluginId,
namespace,
key: normalizedKey,
...(env ? { env } : {}),
});
},
async entries() {
return pluginStateEntries({ pluginId, namespace }) as PluginStateEntry<T>[];
return pluginStateEntries({
pluginId,
namespace,
...(env ? { env } : {}),
}) as PluginStateEntry<T>[];
},
async clear() {
pluginStateClear({ pluginId, namespace });
pluginStateClear({ pluginId, namespace, ...(env ? { env } : {}) });
},
};
}
@@ -297,6 +319,7 @@ function createSyncKeyedStoreForPluginId<T>(
const namespace = validateNamespace(options.namespace);
const maxEntries = validateMaxEntries(options.maxEntries);
const defaultTtlMs = validateOptionalTtlMs(options.defaultTtlMs);
const env = options.env;
assertConsistentOptions(pluginId, namespace, { maxEntries, defaultTtlMs });
return {
@@ -308,6 +331,7 @@ function createSyncKeyedStoreForPluginId<T>(
key: params.key,
valueJson: params.valueJson,
maxEntries,
...(env ? { env } : {}),
...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}),
});
},
@@ -319,26 +343,46 @@ function createSyncKeyedStoreForPluginId<T>(
key: params.key,
valueJson: params.valueJson,
maxEntries,
...(env ? { env } : {}),
...(params.ttlMs != null ? { ttlMs: params.ttlMs } : {}),
});
},
lookup(key) {
const normalizedKey = validateKey(key, "lookup");
return pluginStateLookup({ pluginId, namespace, key: normalizedKey }) as T | undefined;
return pluginStateLookup({
pluginId,
namespace,
key: normalizedKey,
...(env ? { env } : {}),
}) as T | undefined;
},
consume(key) {
const normalizedKey = validateKey(key, "consume");
return pluginStateConsume({ pluginId, namespace, key: normalizedKey }) as T | undefined;
return pluginStateConsume({
pluginId,
namespace,
key: normalizedKey,
...(env ? { env } : {}),
}) as T | undefined;
},
delete(key) {
const normalizedKey = validateKey(key, "delete");
return pluginStateDelete({ pluginId, namespace, key: normalizedKey });
return pluginStateDelete({
pluginId,
namespace,
key: normalizedKey,
...(env ? { env } : {}),
});
},
entries() {
return pluginStateEntries({ pluginId, namespace }) as PluginStateEntry<T>[];
return pluginStateEntries({
pluginId,
namespace,
...(env ? { env } : {}),
}) as PluginStateEntry<T>[];
},
clear() {
pluginStateClear({ pluginId, namespace });
pluginStateClear({ pluginId, namespace, ...(env ? { env } : {}) });
},
};
}

View File

@@ -29,6 +29,7 @@ export type OpenKeyedStoreOptions = {
namespace: string;
maxEntries: number;
defaultTtlMs?: number;
env?: NodeJS.ProcessEnv;
};
export type PluginStateStoreErrorCode =