Files
openclaw/src/gateway/server.sessions.store-rpc.test.ts
Kaspre 5734193fdf fix(plugins): keep metadata snapshot memo fresh
* fix(plugins): keep metadata memo freshness

* fix(plugins): keep metadata memo freshness

* fix(plugins): resolve metadata memo review gaps

* fix(plugins): scope metadata memo watches to env

* fix(plugins): tighten metadata memo fingerprint return type

`resolvePersistedRegistryFastMemoFingerprint` was annotated `: unknown`
but always returns object literals (`{ disabled: true }` or
`{ index, npmPackageJson }`). Spreading the unknown-typed result on
line 478 (`...fastFingerprint`) was rejected by tsgo with TS2698, which
cascaded across every check that runs the project compile (build,
tsgo:prod, check:test-types, lint, all node test shards).

Tighten the return type to `Record<string, unknown>` to match the
function's actual return shapes and unblock the spread.

* test(gateway): tolerate ENOENT in sessions.list spy predicate

The `sessions.list configuredAgentsOnly hides disk-discovered
unregistered agent stores` test spies on `fsSync.readFileSync` and
predicates with `fsSync.realpathSync.native(file) === realDiskOnlyStorePath`
for every captured read. The native realpath call throws on missing
files, so any new readFileSync of a path that may not exist (e.g. the
persisted plugin install records probe added in this PR) crashes the
predicate before the assertion runs.

Wrap the predicate in ENOENT tolerance so the test stays robust against
any future readFileSync of files that may not exist on disk.

* fix(plugins): refresh memo from cached registry

* fix(plugins): use high resolution memo fingerprints

* test(plugins): stabilize memo freshness regression

* test(cli): satisfy config mutation mock hash contract

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-15 07:12:31 +01:00

522 lines
17 KiB
TypeScript

import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { expect, test, vi } from "vitest";
import { piSdkMock, rpcReq, testState, writeSessionStore } from "./test-helpers.js";
import {
directSessionReq as directSessionHandlerReq,
setupGatewaySessionsTestHarness,
getGatewayConfigModule,
getSessionsHandlers,
} from "./test/server-sessions.test-helpers.js";
const { createSessionStoreDir, openClient } = setupGatewaySessionsTestHarness();
function collectNonEmptyLines(text: string): string[] {
const lines: string[] = [];
for (const line of text.split(/\r?\n/)) {
if (line.trim().length > 0) {
lines.push(line);
}
}
return lines;
}
function expectSinglePrefixedFilename(files: string[], prefix: string): string {
const matches = files.filter((file) => file.startsWith(prefix));
expect(matches).toHaveLength(1);
const [match] = matches;
if (!match) {
throw new Error(`Expected one filename with prefix ${prefix}`);
}
expect(match.length).toBeGreaterThan(prefix.length);
return match;
}
test("lists and patches session store via sessions.* RPC", async () => {
const { dir, storePath } = await createSessionStoreDir();
const now = Date.now();
const recent = now - 30_000;
const stale = now - 15 * 60_000;
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
`${Array.from({ length: 10 })
.map((_, idx) => JSON.stringify({ role: "user", content: `line ${idx}` }))
.join("\n")}\n`,
"utf-8",
);
await fs.writeFile(
path.join(dir, "sess-group.jsonl"),
`${JSON.stringify({ role: "user", content: "group line 0" })}\n`,
"utf-8",
);
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: recent,
modelProvider: "anthropic",
model: "claude-sonnet-4-6",
inputTokens: 10,
outputTokens: 20,
thinkingLevel: "low",
verboseLevel: "on",
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "work",
lastThreadId: "1737500000.123456",
},
"discord:group:dev": {
sessionId: "sess-group",
updatedAt: stale,
totalTokens: 50,
},
"agent:main:subagent:one": {
sessionId: "sess-subagent",
updatedAt: stale,
spawnedBy: "agent:main:main",
},
global: {
sessionId: "sess-global",
updatedAt: now - 10_000,
},
},
});
const { ws, hello } = await openClient();
const methods = (hello as { features?: { methods?: string[] } }).features?.methods ?? [];
expect(methods).toContain("sessions.list");
expect(methods).toContain("sessions.preview");
expect(methods).toContain("sessions.cleanup");
expect(methods).toContain("sessions.patch");
expect(methods).toContain("sessions.reset");
expect(methods).toContain("sessions.delete");
expect(methods).toContain("sessions.compact");
const sessionsHandlers = await getSessionsHandlers();
const { getRuntimeConfig } = await getGatewayConfigModule();
const directContext = {
broadcastToConnIds: vi.fn(),
getSessionEventSubscriberConnIds: () => new Set<string>(),
logGateway: { debug: vi.fn() },
loadGatewayModelCatalog: async () => piSdkMock.models,
getRuntimeConfig: getRuntimeConfig,
} as never;
async function directSessionReq<TPayload = unknown>(
method: keyof typeof sessionsHandlers,
params: Record<string, unknown>,
coercePayload?: (payload: unknown) => TPayload,
): Promise<{ ok: boolean; payload?: TPayload; error?: unknown }> {
let result:
| {
ok: boolean;
payload?: TPayload;
error?: unknown;
}
| undefined;
await sessionsHandlers[method]({
req: {} as never,
params,
respond: (ok, payload, error) => {
result = {
ok,
payload:
payload === undefined
? undefined
: coercePayload
? coercePayload(payload)
: (payload as TPayload),
error,
};
},
context: directContext,
client: null,
isWebchatConnect: () => false,
});
if (!result) {
throw new Error(`${method} did not respond`);
}
return result;
}
const resolvedByKey = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", {
key: "main",
});
expect(resolvedByKey.ok).toBe(true);
expect(resolvedByKey.payload?.key).toBe("agent:main:main");
const resolvedBySessionId = await rpcReq<{ ok: true; key: string }>(ws, "sessions.resolve", {
sessionId: "sess-group",
});
expect(resolvedBySessionId.ok).toBe(true);
expect(resolvedBySessionId.payload?.key).toBe("agent:main:discord:group:dev");
ws.close();
const list1 = await directSessionReq<{
path: string;
defaults?: { model?: string | null; modelProvider?: string | null };
sessions: Array<{
key: string;
totalTokens?: number;
totalTokensFresh?: boolean;
thinkingLevel?: string;
verboseLevel?: string;
lastAccountId?: string;
deliveryContext?: { channel?: string; to?: string; accountId?: string };
}>;
}>("sessions.list", { includeGlobal: false, includeUnknown: false });
expect(list1.ok).toBe(true);
expect(list1.payload?.path).toBe(storePath);
expect(list1.payload?.sessions.map((session) => session.key)).not.toContain("global");
expect(list1.payload?.defaults?.modelProvider).toBe("anthropic");
const main = list1.payload?.sessions.find((s) => s.key === "agent:main:main");
expect(main?.totalTokens).toBeUndefined();
expect(main?.totalTokensFresh).toBe(false);
expect(main?.thinkingLevel).toBe("low");
expect(main?.verboseLevel).toBe("on");
expect(main?.lastAccountId).toBe("work");
expect(main?.deliveryContext).toEqual({
channel: "whatsapp",
to: "+1555",
accountId: "work",
threadId: "1737500000.123456",
});
const active = await directSessionReq<{
sessions: Array<{ key: string }>;
}>("sessions.list", {
includeGlobal: false,
includeUnknown: false,
activeMinutes: 5,
});
expect(active.ok).toBe(true);
expect(active.payload?.sessions.map((s) => s.key)).toEqual(["agent:main:main"]);
const limited = await directSessionReq<{
sessions: Array<{ key: string }>;
}>("sessions.list", {
includeGlobal: true,
includeUnknown: false,
limit: 1,
});
expect(limited.ok).toBe(true);
expect(limited.payload?.sessions).toHaveLength(1);
expect(limited.payload?.sessions[0]?.key).toBe("global");
const patched = await directSessionReq<{ ok: true; key: string }>("sessions.patch", {
key: "agent:main:main",
thinkingLevel: "medium",
verboseLevel: "off",
});
expect(patched.ok).toBe(true);
expect(patched.payload?.ok).toBe(true);
expect(patched.payload?.key).toBe("agent:main:main");
const sendPolicyPatched = await directSessionReq<{
ok: true;
entry: { sendPolicy?: string };
}>("sessions.patch", { key: "agent:main:main", sendPolicy: "deny" });
expect(sendPolicyPatched.ok).toBe(true);
expect(sendPolicyPatched.payload?.entry.sendPolicy).toBe("deny");
const labelPatched = await directSessionReq<{
ok: true;
entry: { label?: string };
}>("sessions.patch", {
key: "agent:main:subagent:one",
label: "Briefing",
});
expect(labelPatched.ok).toBe(true);
expect(labelPatched.payload?.entry.label).toBe("Briefing");
const labelPatchedDuplicate = await directSessionReq("sessions.patch", {
key: "agent:main:discord:group:dev",
label: "Briefing",
});
expect(labelPatchedDuplicate.ok).toBe(false);
const list2 = await directSessionReq<{
sessions: Array<{
key: string;
thinkingLevel?: string;
verboseLevel?: string;
sendPolicy?: string;
label?: string;
displayName?: string;
}>;
}>("sessions.list", {});
expect(list2.ok).toBe(true);
const main2 = list2.payload?.sessions.find((s) => s.key === "agent:main:main");
expect(main2?.thinkingLevel).toBe("medium");
expect(main2?.verboseLevel).toBe("off");
expect(main2?.sendPolicy).toBe("deny");
const subagent = list2.payload?.sessions.find((s) => s.key === "agent:main:subagent:one");
expect(subagent?.label).toBe("Briefing");
expect(subagent?.displayName).toBe("Briefing");
const clearedVerbose = await directSessionReq<{ ok: true; key: string }>("sessions.patch", {
key: "agent:main:main",
verboseLevel: null,
});
expect(clearedVerbose.ok).toBe(true);
const list3 = await directSessionReq<{
sessions: Array<{
key: string;
verboseLevel?: string;
}>;
}>("sessions.list", {});
expect(list3.ok).toBe(true);
const main3 = list3.payload?.sessions.find((s) => s.key === "agent:main:main");
expect(main3?.verboseLevel).toBeUndefined();
const listByLabel = await directSessionReq<{
sessions: Array<{ key: string }>;
}>("sessions.list", {
includeGlobal: false,
includeUnknown: false,
label: "Briefing",
});
expect(listByLabel.ok).toBe(true);
expect(listByLabel.payload?.sessions.map((s) => s.key)).toEqual(["agent:main:subagent:one"]);
const resolvedByLabel = await directSessionReq<{ ok: true; key: string }>("sessions.resolve", {
label: "Briefing",
agentId: "main",
});
expect(resolvedByLabel.ok).toBe(true);
expect(resolvedByLabel.payload?.key).toBe("agent:main:subagent:one");
const spawnedOnly = await directSessionReq<{
sessions: Array<{ key: string }>;
}>("sessions.list", {
includeGlobal: true,
includeUnknown: true,
spawnedBy: "agent:main:main",
});
expect(spawnedOnly.ok).toBe(true);
expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual(["agent:main:subagent:one"]);
const spawnedPatched = await directSessionReq<{
ok: true;
entry: { spawnedBy?: string };
}>("sessions.patch", {
key: "agent:main:subagent:two",
spawnedBy: "agent:main:main",
});
expect(spawnedPatched.ok).toBe(true);
expect(spawnedPatched.payload?.entry.spawnedBy).toBe("agent:main:main");
const acpPatched = await directSessionReq<{
ok: true;
entry: { spawnedBy?: string; spawnDepth?: number };
}>("sessions.patch", {
key: "agent:main:acp:child",
spawnedBy: "agent:main:main",
spawnDepth: 1,
});
expect(acpPatched.ok).toBe(true);
expect(acpPatched.payload?.entry.spawnedBy).toBe("agent:main:main");
expect(acpPatched.payload?.entry.spawnDepth).toBe(1);
const spawnedPatchedInvalidKey = await directSessionReq("sessions.patch", {
key: "agent:main:main",
spawnedBy: "agent:main:main",
});
expect(spawnedPatchedInvalidKey.ok).toBe(false);
const cleaned = await directSessionReq<{
applied: true;
missing: number;
appliedCount: number;
}>("sessions.cleanup", {
enforce: true,
fixMissing: true,
});
expect(cleaned.ok).toBe(true);
expect(cleaned.payload?.missing).toBeGreaterThanOrEqual(1);
const listAfterCleanup = await directSessionReq<{
sessions: Array<{ key: string }>;
}>("sessions.list", {});
expect(listAfterCleanup.payload?.sessions.map((session) => session.key)).not.toContain(
"agent:main:subagent:one",
);
piSdkMock.enabled = true;
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
const modelPatched = await directSessionReq<{
ok: true;
entry: {
modelOverride?: string;
providerOverride?: string;
model?: string;
modelProvider?: string;
};
resolved?: {
model?: string;
modelProvider?: string;
agentRuntime?: { id: string; source: string };
};
}>("sessions.patch", {
key: "agent:main:main",
model: "openai/gpt-test-a",
});
expect(modelPatched.ok).toBe(true);
expect(modelPatched.payload?.entry.modelOverride).toBe("gpt-test-a");
expect(modelPatched.payload?.entry.providerOverride).toBe("openai");
expect(modelPatched.payload?.entry.model).toBeUndefined();
expect(modelPatched.payload?.entry.modelProvider).toBeUndefined();
expect(modelPatched.payload?.resolved?.modelProvider).toBe("openai");
expect(modelPatched.payload?.resolved?.model).toBe("gpt-test-a");
expect(modelPatched.payload?.resolved?.agentRuntime).toEqual({
id: "codex",
source: "implicit",
});
const listAfterModelPatch = await directSessionReq<{
sessions: Array<{
key: string;
modelProvider?: string;
model?: string;
agentRuntime?: { id: string; source: string };
}>;
}>("sessions.list", {});
expect(listAfterModelPatch.ok).toBe(true);
const mainAfterModelPatch = listAfterModelPatch.payload?.sessions.find(
(session) => session.key === "agent:main:main",
);
expect(mainAfterModelPatch?.modelProvider).toBe("openai");
expect(mainAfterModelPatch?.model).toBe("gpt-test-a");
expect(mainAfterModelPatch?.agentRuntime).toEqual({ id: "codex", source: "implicit" });
const compacted = await directSessionReq<{ ok: true; compacted: boolean }>("sessions.compact", {
key: "agent:main:main",
maxLines: 3,
});
expect(compacted.ok).toBe(true);
expect(compacted.payload?.compacted).toBe(true);
const compactedLines = collectNonEmptyLines(
await fs.readFile(path.join(dir, "sess-main.jsonl"), "utf-8"),
);
expect(compactedLines).toHaveLength(3);
const filesAfterCompact = await fs.readdir(dir);
expectSinglePrefixedFilename(filesAfterCompact, "sess-main.jsonl.bak.");
const deleted = await directSessionReq<{ ok: true; deleted: boolean }>("sessions.delete", {
key: "agent:main:discord:group:dev",
});
expect(deleted.ok).toBe(true);
expect(deleted.payload?.deleted).toBe(true);
const listAfterDelete = await directSessionReq<{
sessions: Array<{ key: string }>;
}>("sessions.list", {});
expect(listAfterDelete.ok).toBe(true);
expect(listAfterDelete.payload?.sessions.map((session) => session.key)).not.toContain(
"agent:main:discord:group:dev",
);
const filesAfterDelete = await fs.readdir(dir);
expectSinglePrefixedFilename(filesAfterDelete, "sess-group.jsonl.deleted.");
const reset = await directSessionReq<{
ok: true;
key: string;
entry: {
sessionId: string;
modelProvider?: string;
model?: string;
lastAccountId?: string;
lastThreadId?: string | number;
};
}>("sessions.reset", { key: "agent:main:main" });
expect(reset.ok).toBe(true);
expect(reset.payload?.key).toBe("agent:main:main");
expect(reset.payload?.entry.sessionId).not.toBe("sess-main");
expect(reset.payload?.entry.modelProvider).toBe("openai");
expect(reset.payload?.entry.model).toBe("gpt-test-a");
expect(reset.payload?.entry.lastAccountId).toBe("work");
expect(reset.payload?.entry.lastThreadId).toBe("1737500000.123456");
const storeAfterReset = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
{ lastAccountId?: string; lastThreadId?: string | number }
>;
expect(storeAfterReset["agent:main:main"]?.lastAccountId).toBe("work");
expect(storeAfterReset["agent:main:main"]?.lastThreadId).toBe("1737500000.123456");
const filesAfterReset = await fs.readdir(dir);
expectSinglePrefixedFilename(filesAfterReset, "sess-main.jsonl.reset.");
const badThinking = await directSessionReq("sessions.patch", {
key: "agent:main:main",
thinkingLevel: "banana",
});
expect(badThinking.ok).toBe(false);
expect((badThinking.error as { message?: unknown } | undefined)?.message ?? "").toMatch(
/invalid thinkinglevel/i,
);
});
test("sessions.list configuredAgentsOnly hides disk-discovered unregistered agent stores", async () => {
const stateDir = process.env.OPENCLAW_STATE_DIR;
if (!stateDir) {
throw new Error("OPENCLAW_STATE_DIR is required for gateway session tests");
}
testState.agentsConfig = { list: [{ id: "main", default: true }] };
testState.sessionConfig = {
store: path.join(stateDir, "agents", "{agentId}", "sessions", "sessions.json"),
};
const mainStorePath = path.join(stateDir, "agents", "main", "sessions", "sessions.json");
const diskOnlyStorePath = path.join(stateDir, "agents", "local", "sessions", "sessions.json");
await fs.mkdir(path.dirname(mainStorePath), { recursive: true });
await fs.mkdir(path.dirname(diskOnlyStorePath), { recursive: true });
await fs.writeFile(
mainStorePath,
JSON.stringify({ main: { sessionId: "sess-main", updatedAt: 20 } }, null, 2),
"utf-8",
);
await fs.writeFile(
diskOnlyStorePath,
JSON.stringify({ main: { sessionId: "sess-local", updatedAt: 10 } }, null, 2),
"utf-8",
);
const readFileSyncSpy = vi.spyOn(fsSync, "readFileSync");
const realDiskOnlyStorePath = await fs.realpath(diskOnlyStorePath);
try {
const configuredOnly = await directSessionHandlerReq<{ sessions: Array<{ key: string }> }>(
"sessions.list",
{ includeGlobal: false, includeUnknown: false, configuredAgentsOnly: true },
);
expect(configuredOnly.ok).toBe(true);
expect(configuredOnly.payload?.sessions.map((session) => session.key)).toEqual([
"agent:main:main",
]);
expect(
readFileSyncSpy.mock.calls.some(([file]) => {
if (typeof file !== "string") {
return false;
}
try {
return fsSync.realpathSync.native(file) === realDiskOnlyStorePath;
} catch {
return false;
}
}),
).toBe(false);
} finally {
readFileSyncSpy.mockRestore();
}
const broad = await directSessionHandlerReq<{ sessions: Array<{ key: string }> }>(
"sessions.list",
{ includeGlobal: false, includeUnknown: false },
);
expect(broad.ok).toBe(true);
expect(broad.payload?.sessions.map((session) => session.key)).toEqual([
"agent:main:main",
"agent:local:main",
]);
});