555e282cc4
pi-agent-plugin checks / lint (push) Has been cancelled
pi-agent-plugin checks / test (20) (push) Has been cancelled
pi-agent-plugin checks / test (22) (push) Has been cancelled
pi-agent-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / check_changes (push) Has been cancelled
TypeScript SDK CI / changelog_check (push) Has been cancelled
ci / changelog_check (push) Has been cancelled
ci / check_changes (push) Has been cancelled
ci / build_mem0 (3.10) (push) Has been cancelled
ci / build_mem0 (3.11) (push) Has been cancelled
ci / build_mem0 (3.12) (push) Has been cancelled
CLI Node CI / lint (push) Has been cancelled
CLI Node CI / test (20) (push) Has been cancelled
CLI Node CI / test (22) (push) Has been cancelled
CLI Node CI / build (push) Has been cancelled
CLI Python CI / lint (push) Has been cancelled
CLI Python CI / test (3.10) (push) Has been cancelled
CLI Python CI / test (3.11) (push) Has been cancelled
CLI Python CI / test (3.12) (push) Has been cancelled
CLI Python CI / build (push) Has been cancelled
openclaw checks / lint (push) Has been cancelled
openclaw checks / test (20) (push) Has been cancelled
openclaw checks / test (22) (push) Has been cancelled
openclaw checks / build (push) Has been cancelled
opencode-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (22) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (22) (push) Has been cancelled
377 lines
10 KiB
TypeScript
377 lines
10 KiB
TypeScript
/**
|
|
* Tests for dream-gate.ts — activity tracking, gate logic, and lock mechanism
|
|
* for automatic memory consolidation.
|
|
*
|
|
* All filesystem operations are mocked via fs-safe.ts.
|
|
* Time-dependent tests use vi.useFakeTimers().
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
vi.mock("../fs-safe.ts", () => ({
|
|
readText: vi.fn(),
|
|
writeText: vi.fn(),
|
|
mkdirp: vi.fn(),
|
|
unlink: vi.fn(),
|
|
}));
|
|
|
|
import { readText, writeText, mkdirp, unlink } from "../fs-safe.ts";
|
|
import {
|
|
incrementSessionCount,
|
|
checkCheapGates,
|
|
checkMemoryGate,
|
|
acquireDreamLock,
|
|
releaseDreamLock,
|
|
recordDreamCompletion,
|
|
getDreamState,
|
|
} from "../dream-gate.ts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockReadText = readText as ReturnType<typeof vi.fn>;
|
|
const mockWriteText = writeText as ReturnType<typeof vi.fn>;
|
|
const mockMkdirp = mkdirp as ReturnType<typeof vi.fn>;
|
|
const mockUnlink = unlink as ReturnType<typeof vi.fn>;
|
|
|
|
const STATE_DIR = "/tmp/test-state";
|
|
|
|
interface DreamState {
|
|
lastConsolidatedAt: number;
|
|
sessionsSince: number;
|
|
lastSessionId: string | null;
|
|
}
|
|
|
|
function setDreamState(state: DreamState): void {
|
|
mockReadText.mockImplementation((filePath: string) => {
|
|
if (filePath.endsWith("dream-state.json")) {
|
|
return JSON.stringify(state);
|
|
}
|
|
throw new Error("ENOENT");
|
|
});
|
|
}
|
|
|
|
function setNoState(): void {
|
|
mockReadText.mockImplementation(() => {
|
|
throw new Error("ENOENT");
|
|
});
|
|
}
|
|
|
|
function getWrittenState(): DreamState {
|
|
const call = mockWriteText.mock.calls.find((c: unknown[]) =>
|
|
(c[0] as string).endsWith("dream-state.json"),
|
|
);
|
|
if (!call) throw new Error("No state file written");
|
|
return JSON.parse(call[1] as string);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
mockMkdirp.mockReturnValue(undefined);
|
|
mockUnlink.mockReturnValue(undefined);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// incrementSessionCount
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("incrementSessionCount", () => {
|
|
it("increments counter for a new session", () => {
|
|
setDreamState({
|
|
lastConsolidatedAt: 0,
|
|
sessionsSince: 3,
|
|
lastSessionId: "session-old",
|
|
});
|
|
|
|
incrementSessionCount(STATE_DIR, "session-new");
|
|
|
|
const written = getWrittenState();
|
|
expect(written.sessionsSince).toBe(4);
|
|
expect(written.lastSessionId).toBe("session-new");
|
|
});
|
|
|
|
it("deduplicates same session (no increment)", () => {
|
|
setDreamState({
|
|
lastConsolidatedAt: 0,
|
|
sessionsSince: 3,
|
|
lastSessionId: "session-same",
|
|
});
|
|
|
|
incrementSessionCount(STATE_DIR, "session-same");
|
|
|
|
// writeText should NOT have been called for the state file
|
|
const stateWrites = mockWriteText.mock.calls.filter((c: unknown[]) =>
|
|
(c[0] as string).endsWith("dream-state.json"),
|
|
);
|
|
expect(stateWrites).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// checkCheapGates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("checkCheapGates", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("fails time gate when consolidation was too recent", () => {
|
|
const now = Date.now();
|
|
vi.setSystemTime(now);
|
|
|
|
// Last consolidated 1 hour ago, but minHours is 24
|
|
setDreamState({
|
|
lastConsolidatedAt: now - 1 * 3_600_000,
|
|
sessionsSince: 100,
|
|
lastSessionId: null,
|
|
});
|
|
|
|
const result = checkCheapGates(STATE_DIR, { minHours: 24, minSessions: 5 });
|
|
expect(result.proceed).toBe(false);
|
|
expect(result.reason).toContain("time");
|
|
});
|
|
|
|
it("fails session gate when too few sessions", () => {
|
|
const now = Date.now();
|
|
vi.setSystemTime(now);
|
|
|
|
// Last consolidated 48 hours ago (passes time gate), but only 2 sessions
|
|
setDreamState({
|
|
lastConsolidatedAt: now - 48 * 3_600_000,
|
|
sessionsSince: 2,
|
|
lastSessionId: null,
|
|
});
|
|
|
|
const result = checkCheapGates(STATE_DIR, {
|
|
minHours: 24,
|
|
minSessions: 5,
|
|
});
|
|
expect(result.proceed).toBe(false);
|
|
expect(result.reason).toContain("sessions");
|
|
});
|
|
|
|
it("passes both gates when conditions are met", () => {
|
|
const now = Date.now();
|
|
vi.setSystemTime(now);
|
|
|
|
// 48 hours ago, 10 sessions — both gates pass
|
|
setDreamState({
|
|
lastConsolidatedAt: now - 48 * 3_600_000,
|
|
sessionsSince: 10,
|
|
lastSessionId: null,
|
|
});
|
|
|
|
const result = checkCheapGates(STATE_DIR, {
|
|
minHours: 24,
|
|
minSessions: 5,
|
|
});
|
|
expect(result.proceed).toBe(true);
|
|
expect(result.reason).toBeUndefined();
|
|
});
|
|
|
|
it("uses defaults when config is empty", () => {
|
|
const now = Date.now();
|
|
vi.setSystemTime(now);
|
|
|
|
// Never consolidated (0), 100 sessions — should pass with defaults (24h, 5 sessions)
|
|
setDreamState({
|
|
lastConsolidatedAt: 0,
|
|
sessionsSince: 100,
|
|
lastSessionId: null,
|
|
});
|
|
|
|
const result = checkCheapGates(STATE_DIR, {});
|
|
expect(result.proceed).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// checkMemoryGate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("checkMemoryGate", () => {
|
|
it("fails when too few memories", () => {
|
|
const result = checkMemoryGate(5, { minMemories: 20 });
|
|
expect(result.pass).toBe(false);
|
|
expect(result.reason).toContain("memories");
|
|
expect(result.reason).toContain("5");
|
|
});
|
|
|
|
it("passes when enough memories", () => {
|
|
const result = checkMemoryGate(25, { minMemories: 20 });
|
|
expect(result.pass).toBe(true);
|
|
expect(result.reason).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// acquireDreamLock
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("acquireDreamLock", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("succeeds when no lock exists", () => {
|
|
const now = Date.now();
|
|
vi.setSystemTime(now);
|
|
|
|
// readText throws for lock file (not found), writeText succeeds for wx create
|
|
mockReadText.mockImplementation(() => {
|
|
throw new Error("ENOENT");
|
|
});
|
|
mockWriteText.mockReturnValue(undefined);
|
|
|
|
const result = acquireDreamLock(STATE_DIR);
|
|
expect(result).toBe(true);
|
|
|
|
// Verify it wrote a lock file with wx flag
|
|
const lockWrite = mockWriteText.mock.calls.find((c: unknown[]) =>
|
|
(c[0] as string).endsWith("dream.lock"),
|
|
);
|
|
expect(lockWrite).toBeDefined();
|
|
const lockData = JSON.parse(lockWrite![1] as string);
|
|
expect(lockData.pid).toBe(process.pid);
|
|
expect(lockData.startedAt).toBe(now);
|
|
expect(lockWrite![2]).toEqual({ flag: "wx" });
|
|
});
|
|
|
|
it("fails when lock exists and is fresh", () => {
|
|
const now = Date.now();
|
|
vi.setSystemTime(now);
|
|
|
|
// Lock was created 10 minutes ago — still fresh (< 1 hour)
|
|
mockReadText.mockImplementation((filePath: string) => {
|
|
if (filePath.endsWith("dream.lock")) {
|
|
return JSON.stringify({
|
|
pid: 12345,
|
|
startedAt: now - 10 * 60 * 1000,
|
|
});
|
|
}
|
|
throw new Error("ENOENT");
|
|
});
|
|
|
|
const result = acquireDreamLock(STATE_DIR);
|
|
expect(result).toBe(false);
|
|
|
|
// Should NOT have written a new lock
|
|
const lockWrites = mockWriteText.mock.calls.filter((c: unknown[]) =>
|
|
(c[0] as string).endsWith("dream.lock"),
|
|
);
|
|
expect(lockWrites).toHaveLength(0);
|
|
});
|
|
|
|
it("succeeds when lock is stale (>1hr old)", () => {
|
|
const now = Date.now();
|
|
vi.setSystemTime(now);
|
|
|
|
// Lock was created 2 hours ago — stale
|
|
mockReadText.mockImplementation((filePath: string) => {
|
|
if (filePath.endsWith("dream.lock")) {
|
|
return JSON.stringify({
|
|
pid: 99999,
|
|
startedAt: now - 2 * 60 * 60 * 1000,
|
|
});
|
|
}
|
|
throw new Error("ENOENT");
|
|
});
|
|
mockWriteText.mockReturnValue(undefined);
|
|
|
|
const result = acquireDreamLock(STATE_DIR);
|
|
expect(result).toBe(true);
|
|
|
|
// Should have unlinked the stale lock
|
|
expect(mockUnlink).toHaveBeenCalled();
|
|
|
|
// Should have written a new lock
|
|
const lockWrite = mockWriteText.mock.calls.find((c: unknown[]) =>
|
|
(c[0] as string).endsWith("dream.lock"),
|
|
);
|
|
expect(lockWrite).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// releaseDreamLock
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("releaseDreamLock", () => {
|
|
it("removes lock file", () => {
|
|
releaseDreamLock(STATE_DIR);
|
|
expect(mockUnlink).toHaveBeenCalledWith(
|
|
expect.stringContaining("dream.lock"),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// recordDreamCompletion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("recordDreamCompletion", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("resets session counter and records timestamp", () => {
|
|
const now = 1700000000000;
|
|
vi.setSystemTime(now);
|
|
|
|
setDreamState({
|
|
lastConsolidatedAt: 0,
|
|
sessionsSince: 15,
|
|
lastSessionId: "session-xyz",
|
|
});
|
|
|
|
recordDreamCompletion(STATE_DIR);
|
|
|
|
const written = getWrittenState();
|
|
expect(written.lastConsolidatedAt).toBe(now);
|
|
expect(written.sessionsSince).toBe(0);
|
|
expect(written.lastSessionId).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getDreamState
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getDreamState", () => {
|
|
it("returns default state when no file exists", () => {
|
|
setNoState();
|
|
|
|
const state = getDreamState(STATE_DIR);
|
|
expect(state).toEqual({
|
|
lastConsolidatedAt: 0,
|
|
sessionsSince: 0,
|
|
lastSessionId: null,
|
|
});
|
|
});
|
|
|
|
it("returns persisted state when file exists", () => {
|
|
const persisted = {
|
|
lastConsolidatedAt: 1700000000000,
|
|
sessionsSince: 7,
|
|
lastSessionId: "session-abc",
|
|
};
|
|
setDreamState(persisted);
|
|
|
|
const state = getDreamState(STATE_DIR);
|
|
expect(state).toEqual(persisted);
|
|
});
|
|
});
|