mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 19:08:27 +00:00
202 lines
6.4 KiB
TypeScript
202 lines
6.4 KiB
TypeScript
import fs from "node:fs";
|
|
import fsPromises from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
import type { SessionConfig } from "../types.base.js";
|
|
import type { SessionEntry } from "./types.js";
|
|
import {
|
|
clearSessionStoreCacheForTest,
|
|
loadSessionStore,
|
|
updateSessionStore,
|
|
} from "../sessions.js";
|
|
import {
|
|
resolveSessionFilePath,
|
|
resolveSessionTranscriptPathInDir,
|
|
validateSessionId,
|
|
} from "./paths.js";
|
|
import { resolveSessionResetPolicy } from "./reset.js";
|
|
import { appendAssistantMessageToSessionTranscript } from "./transcript.js";
|
|
|
|
describe("session path safety", () => {
|
|
it("rejects unsafe session IDs", () => {
|
|
expect(() => validateSessionId("../etc/passwd")).toThrow(/Invalid session ID/);
|
|
expect(() => validateSessionId("a/b")).toThrow(/Invalid session ID/);
|
|
expect(() => validateSessionId("a\\b")).toThrow(/Invalid session ID/);
|
|
expect(() => validateSessionId("/abs")).toThrow(/Invalid session ID/);
|
|
});
|
|
|
|
it("resolves transcript path inside an explicit sessions dir", () => {
|
|
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
|
|
const resolved = resolveSessionTranscriptPathInDir("sess-1", sessionsDir, "topic/a+b");
|
|
|
|
expect(resolved).toBe(path.resolve(sessionsDir, "sess-1-topic-topic%2Fa%2Bb.jsonl"));
|
|
});
|
|
|
|
it("rejects absolute sessionFile paths outside known agent sessions dirs", () => {
|
|
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
|
|
|
|
expect(() =>
|
|
resolveSessionFilePath(
|
|
"sess-1",
|
|
{ sessionFile: "/tmp/openclaw/agents/work/not-sessions/abc-123.jsonl" },
|
|
{ sessionsDir },
|
|
),
|
|
).toThrow(/within sessions directory/);
|
|
});
|
|
});
|
|
|
|
describe("resolveSessionResetPolicy", () => {
|
|
describe("backward compatibility: resetByType.dm -> direct", () => {
|
|
it("does not use dm fallback for group/thread types", () => {
|
|
const sessionCfg = {
|
|
resetByType: {
|
|
dm: { mode: "idle" as const, idleMinutes: 45 },
|
|
},
|
|
} as unknown as SessionConfig;
|
|
|
|
const groupPolicy = resolveSessionResetPolicy({
|
|
sessionCfg,
|
|
resetType: "group",
|
|
});
|
|
|
|
expect(groupPolicy.mode).toBe("daily");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("session store lock (Promise chain mutex)", () => {
|
|
let lockFixtureRoot = "";
|
|
let lockCaseId = 0;
|
|
let lockTmpDirs: string[] = [];
|
|
|
|
async function makeTmpStore(
|
|
initial: Record<string, unknown> = {},
|
|
): Promise<{ dir: string; storePath: string }> {
|
|
const dir = path.join(lockFixtureRoot, `case-${lockCaseId++}`);
|
|
await fsPromises.mkdir(dir);
|
|
lockTmpDirs.push(dir);
|
|
const storePath = path.join(dir, "sessions.json");
|
|
if (Object.keys(initial).length > 0) {
|
|
await fsPromises.writeFile(storePath, JSON.stringify(initial, null, 2), "utf-8");
|
|
}
|
|
return { dir, storePath };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
lockFixtureRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), "openclaw-lock-test-"));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (lockFixtureRoot) {
|
|
await fsPromises.rm(lockFixtureRoot, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
afterEach(async () => {
|
|
clearSessionStoreCacheForTest();
|
|
lockTmpDirs = [];
|
|
});
|
|
|
|
it("serializes concurrent updateSessionStore calls without data loss", async () => {
|
|
const key = "agent:main:test";
|
|
const { storePath } = await makeTmpStore({
|
|
[key]: { sessionId: "s1", updatedAt: 100, counter: 0 },
|
|
});
|
|
|
|
const N = 4;
|
|
await Promise.all(
|
|
Array.from({ length: N }, (_, i) =>
|
|
updateSessionStore(storePath, async (store) => {
|
|
const entry = store[key] as Record<string, unknown>;
|
|
await Promise.resolve();
|
|
entry.counter = (entry.counter as number) + 1;
|
|
entry.tag = `writer-${i}`;
|
|
}),
|
|
),
|
|
);
|
|
|
|
const store = loadSessionStore(storePath);
|
|
expect((store[key] as Record<string, unknown>).counter).toBe(N);
|
|
});
|
|
|
|
it("multiple consecutive errors do not permanently poison the queue", async () => {
|
|
const key = "agent:main:multi-err";
|
|
const { storePath } = await makeTmpStore({
|
|
[key]: { sessionId: "s1", updatedAt: 100 },
|
|
});
|
|
|
|
const errors = Array.from({ length: 3 }, (_, i) =>
|
|
updateSessionStore(storePath, async () => {
|
|
throw new Error(`fail-${i}`);
|
|
}),
|
|
);
|
|
|
|
const success = updateSessionStore(storePath, async (store) => {
|
|
store[key] = { ...store[key], modelOverride: "recovered" } as unknown as SessionEntry;
|
|
});
|
|
|
|
for (const p of errors) {
|
|
await expect(p).rejects.toThrow();
|
|
}
|
|
await success;
|
|
|
|
const store = loadSessionStore(storePath);
|
|
expect(store[key]?.modelOverride).toBe("recovered");
|
|
});
|
|
});
|
|
|
|
describe("appendAssistantMessageToSessionTranscript", () => {
|
|
let tempDir: string;
|
|
let storePath: string;
|
|
let sessionsDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-test-"));
|
|
sessionsDir = path.join(tempDir, "agents", "main", "sessions");
|
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
storePath = path.join(sessionsDir, "sessions.json");
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("creates transcript file and appends message for valid session", async () => {
|
|
const sessionId = "test-session-id";
|
|
const sessionKey = "test-session";
|
|
const store = {
|
|
[sessionKey]: {
|
|
sessionId,
|
|
chatType: "direct",
|
|
channel: "discord",
|
|
},
|
|
};
|
|
fs.writeFileSync(storePath, JSON.stringify(store), "utf-8");
|
|
|
|
const result = await appendAssistantMessageToSessionTranscript({
|
|
sessionKey,
|
|
text: "Hello from delivery mirror!",
|
|
storePath,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(fs.existsSync(result.sessionFile)).toBe(true);
|
|
|
|
const lines = fs.readFileSync(result.sessionFile, "utf-8").trim().split("\n");
|
|
expect(lines.length).toBe(2);
|
|
|
|
const header = JSON.parse(lines[0]);
|
|
expect(header.type).toBe("session");
|
|
expect(header.id).toBe(sessionId);
|
|
|
|
const messageLine = JSON.parse(lines[1]);
|
|
expect(messageLine.type).toBe("message");
|
|
expect(messageLine.message.role).toBe("assistant");
|
|
expect(messageLine.message.content[0].type).toBe("text");
|
|
expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!");
|
|
}
|
|
});
|
|
});
|