Files
openclaw/src/plugin-sdk/session-store-runtime.test.ts

347 lines
9.5 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as jsonFiles from "../infra/json-files.js";
import {
cleanupSessionLifecycleArtifacts,
getSessionEntry,
listSessionEntries,
patchSessionEntry,
readSessionUpdatedAt,
saveSessionStore,
updateSessionStore,
updateSessionStoreEntry,
upsertSessionEntry,
} from "./session-store-runtime.js";
const DAY_MS = 24 * 60 * 60 * 1000;
describe("session-store-runtime compatibility surface", () => {
let tempDir: string;
let storePath: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-session-store-"));
storePath = path.join(tempDir, "sessions.json");
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it("keeps the public session read shape while using accessor-backed exports", async () => {
const sessionKey = "agent:main:main";
await upsertSessionEntry({
sessionKey,
storePath,
entry: {
model: "gpt-5.5",
sessionId: "session-1",
updatedAt: 10,
},
});
expect(getSessionEntry({ sessionKey, storePath })).toMatchObject({
model: "gpt-5.5",
sessionId: "session-1",
updatedAt: 10,
});
expect(readSessionUpdatedAt({ sessionKey, storePath })).toEqual(expect.any(Number));
expect(listSessionEntries({ storePath })).toEqual([
{
sessionKey,
entry: expect.objectContaining({
model: "gpt-5.5",
sessionId: "session-1",
updatedAt: 10,
}),
},
]);
await upsertSessionEntry({
sessionKey,
storePath,
entry: {
sessionId: "session-1",
updatedAt: 20,
},
});
expect(getSessionEntry({ sessionKey, storePath })?.model).toBeUndefined();
});
it("keeps the public entry mutation signature while delegating to the seam", async () => {
const sessionKey = "agent:main:main";
await expect(
updateSessionStoreEntry({
sessionKey,
storePath,
update: () => ({ model: "gpt-5.5" }),
}),
).resolves.toBeNull();
await upsertSessionEntry({
sessionKey,
storePath,
entry: {
sessionId: "session-1",
updatedAt: 10,
},
});
const beforePatch = getSessionEntry({ sessionKey, storePath });
await expect(
patchSessionEntry({
sessionKey,
storePath,
preserveActivity: true,
update: (_entry, context) => ({
providerOverride: context.existingEntry ? "openai" : "missing",
updatedAt: 20,
}),
}),
).resolves.toMatchObject({
providerOverride: "openai",
sessionId: "session-1",
updatedAt: beforePatch?.updatedAt,
});
await expect(
updateSessionStoreEntry({
sessionKey,
storePath,
update: () => ({ model: "gpt-5.5" }),
}),
).resolves.toMatchObject({
model: "gpt-5.5",
providerOverride: "openai",
sessionId: "session-1",
});
});
it("preserves resolved maintenance settings through entry patches", async () => {
const staleSessionKey = "agent:main:stale";
const activeSessionKey = "agent:main:active";
await saveSessionStore(
storePath,
{
[staleSessionKey]: {
sessionId: "session-stale",
updatedAt: 10,
},
[activeSessionKey]: {
sessionId: "session-active",
updatedAt: 20,
},
},
{ skipMaintenance: true },
);
await expect(
patchSessionEntry({
sessionKey: activeSessionKey,
storePath,
maintenanceConfig: {
mode: "enforce",
pruneAfterMs: 7 * DAY_MS,
modelRunPruneAfterMs: DAY_MS,
maxEntries: 1,
resetArchiveRetentionMs: 7 * DAY_MS,
maxDiskBytes: null,
highWaterBytes: null,
},
update: () => ({ model: "gpt-5.5" }),
}),
).resolves.toMatchObject({
model: "gpt-5.5",
sessionId: "session-active",
});
expect(getSessionEntry({ sessionKey: activeSessionKey, storePath })).toMatchObject({
model: "gpt-5.5",
sessionId: "session-active",
});
expect(getSessionEntry({ sessionKey: staleSessionKey, storePath })).toBeUndefined();
});
it("accepts pre-model-run maintenance configs through entry patches", async () => {
const staleModelRunKey = "agent:main:explicit:model-run-123e4567-e89b-12d3-a456-426614174000";
const activeSessionKey = "agent:main:active";
const now = Date.now();
await saveSessionStore(
storePath,
{
[staleModelRunKey]: {
sessionId: "session-probe",
updatedAt: now - 2 * DAY_MS,
},
[activeSessionKey]: {
sessionId: "session-active",
updatedAt: now,
},
},
{ skipMaintenance: true },
);
const legacyMaintenanceConfig = {
mode: "enforce" as const,
pruneAfterMs: 7 * DAY_MS,
maxEntries: 500,
resetArchiveRetentionMs: 7 * DAY_MS,
maxDiskBytes: null,
highWaterBytes: null,
};
await expect(
patchSessionEntry({
sessionKey: activeSessionKey,
storePath,
maintenanceConfig: legacyMaintenanceConfig,
update: () => ({ model: "gpt-5.5" }),
}),
).resolves.toMatchObject({
model: "gpt-5.5",
sessionId: "session-active",
});
expect(getSessionEntry({ sessionKey: staleModelRunKey, storePath })).toMatchObject({
sessionId: "session-probe",
});
});
it("keeps deprecated whole-store mutations grouped as one compatibility operation", async () => {
const firstSessionKey = "agent:main:first";
const secondSessionKey = "agent:main:second";
const deletedSessionKey = "agent:main:deleted";
await saveSessionStore(
storePath,
{
[firstSessionKey]: {
sessionId: "session-1",
updatedAt: 10,
},
[secondSessionKey]: {
sessionId: "session-2",
updatedAt: 10,
},
[deletedSessionKey]: {
sessionId: "session-3",
updatedAt: 10,
},
},
{ skipMaintenance: true },
);
await expect(
updateSessionStore(
storePath,
(store) => {
const first = store[firstSessionKey];
const second = store[secondSessionKey];
if (!first || !second) {
throw new Error("seed session entries missing");
}
store[firstSessionKey] = {
...first,
model: "gpt-5.5",
updatedAt: 20,
};
store[secondSessionKey] = {
...second,
providerOverride: "openai",
updatedAt: 30,
};
delete store[deletedSessionKey];
return "whole-store-updated";
},
{ skipMaintenance: true },
),
).resolves.toBe("whole-store-updated");
expect(getSessionEntry({ sessionKey: firstSessionKey, storePath })).toMatchObject({
model: "gpt-5.5",
sessionId: "session-1",
updatedAt: 20,
});
expect(getSessionEntry({ sessionKey: secondSessionKey, storePath })).toMatchObject({
providerOverride: "openai",
sessionId: "session-2",
updatedAt: 30,
});
expect(getSessionEntry({ sessionKey: deletedSessionKey, storePath })).toBeUndefined();
});
it("preserves requireWriteSuccess for critical session entry updates", async () => {
const sessionKey = "agent:main:main";
await upsertSessionEntry({
sessionKey,
storePath,
entry: {
sessionId: "session-1",
updatedAt: 10,
},
});
const writeError = Object.assign(new Error("write failed"), { code: "ENOENT" });
const writeSpy = vi.spyOn(jsonFiles, "writeTextAtomic").mockRejectedValue(writeError);
try {
await expect(
updateSessionStoreEntry({
sessionKey,
storePath,
requireWriteSuccess: true,
update: () => ({ model: "gpt-5.5" }),
}),
).rejects.toBe(writeError);
} finally {
writeSpy.mockRestore();
}
});
it("cleans lifecycle artifacts through the accessor-backed SDK wrapper", async () => {
const sessionKey = "agent:main:lifecycle-owned-old";
const transcriptPath = path.join(tempDir, "lifecycle-owned-old.jsonl");
await saveSessionStore(
storePath,
{
[sessionKey]: {
sessionFile: transcriptPath,
sessionId: "lifecycle-owned-old",
updatedAt: 10,
},
"agent:main:regular": {
sessionId: "regular",
updatedAt: 20,
},
},
{ skipMaintenance: true },
);
fs.writeFileSync(transcriptPath, '{"runId":"lifecycle-owned-old"}\n', "utf-8");
const oldDate = new Date(Date.now() - 600_000);
fs.utimesSync(transcriptPath, oldDate, oldDate);
await expect(
cleanupSessionLifecycleArtifacts({
storePath,
sessionKeySegmentPrefix: "lifecycle-owned-",
transcriptContentMarker: '"runId":"lifecycle-owned-',
orphanTranscriptMinAgeMs: 300_000,
}),
).resolves.toEqual({
archivedTranscriptArtifacts: 1,
removedEntries: 1,
});
expect(getSessionEntry({ sessionKey, storePath })).toBeUndefined();
expect(getSessionEntry({ sessionKey: "agent:main:regular", storePath })).toMatchObject({
sessionId: "regular",
});
expect(
fs
.readdirSync(tempDir)
.filter((file) => file.startsWith("lifecycle-owned-old.jsonl.deleted.")),
).toHaveLength(1);
});
});