fix: openclaw plugin and fix the login section there (#4696)

Co-authored-by: Saket Aryan <saketaryan2002@gmail.com>
This commit is contained in:
Kartik
2026-04-04 22:21:46 +05:30
committed by GitHub
co-authored by Saket Aryan
parent c55447c1e4
commit 686d5e987d
33 changed files with 4282 additions and 1882 deletions
File diff suppressed because it is too large Load Diff
+243
View File
@@ -0,0 +1,243 @@
/**
* Tests for cli/config-file.ts — file-based config helpers.
*
* All filesystem operations are mocked via fs-safe.ts so tests never
* touch the real disk.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../fs-safe.ts", () => ({
readText: vi.fn(),
exists: vi.fn(),
writeText: vi.fn(),
mkdirp: vi.fn(),
unlink: vi.fn(),
}));
import { readText, exists, writeText, mkdirp } from "../fs-safe.ts";
import {
readPluginAuth,
writePluginAuth,
getBaseUrl,
DEFAULT_BASE_URL,
} from "../cli/config-file.ts";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const mockExists = exists as ReturnType<typeof vi.fn>;
const mockReadText = readText as ReturnType<typeof vi.fn>;
const mockWriteText = writeText as ReturnType<typeof vi.fn>;
const mockMkdirp = mkdirp as ReturnType<typeof vi.fn>;
function setConfigFile(obj: Record<string, unknown>): void {
mockExists.mockReturnValue(true);
mockReadText.mockReturnValue(JSON.stringify(obj));
}
function setNoFile(): void {
mockExists.mockReturnValue(false);
}
beforeEach(() => {
vi.resetAllMocks();
});
// ---------------------------------------------------------------------------
// readPluginAuth
// ---------------------------------------------------------------------------
describe("readPluginAuth", () => {
it("returns empty object when config file does not exist", () => {
setNoFile();
expect(readPluginAuth()).toEqual({});
});
it("returns empty object when config has no plugins section", () => {
setConfigFile({ someOtherKey: true });
expect(readPluginAuth()).toEqual({});
});
it("reads all fields correctly from nested config", () => {
setConfigFile({
plugins: {
entries: {
"openclaw-mem0": {
enabled: true,
config: {
apiKey: "sk-test-123",
baseUrl: "https://custom.api.com",
userId: "user-1",
orgId: "org-1",
projectId: "proj-1",
mode: "platform",
enableGraph: true,
autoRecall: true,
autoCapture: false,
topK: 10,
},
},
},
},
});
const auth = readPluginAuth();
expect(auth).toEqual({
apiKey: "sk-test-123",
baseUrl: "https://custom.api.com",
userId: "user-1",
orgId: "org-1",
projectId: "proj-1",
mode: "platform",
enableGraph: true,
autoRecall: true,
autoCapture: false,
topK: 10,
});
});
it("handles snake_case aliases (api_key, base_url, user_id, org_id, project_id)", () => {
setConfigFile({
plugins: {
entries: {
"openclaw-mem0": {
enabled: true,
config: {
api_key: "sk-snake",
base_url: "https://snake.api.com",
user_id: "user-snake",
org_id: "org-snake",
project_id: "proj-snake",
},
},
},
},
});
const auth = readPluginAuth();
expect(auth.apiKey).toBe("sk-snake");
expect(auth.baseUrl).toBe("https://snake.api.com");
expect(auth.userId).toBe("user-snake");
expect(auth.orgId).toBe("org-snake");
expect(auth.projectId).toBe("proj-snake");
});
it("returns empty object when JSON is invalid", () => {
mockExists.mockReturnValue(true);
mockReadText.mockReturnValue("not valid json {{{");
expect(readPluginAuth()).toEqual({});
});
});
// ---------------------------------------------------------------------------
// writePluginAuth
// ---------------------------------------------------------------------------
describe("writePluginAuth", () => {
it("creates nested structure from scratch when no config exists", () => {
setNoFile();
// exists returns false for both the file (readFullConfig) and the dir (writeFullConfig)
mockExists.mockReturnValue(false);
writePluginAuth({ apiKey: "sk-new", userId: "u1" });
expect(mockMkdirp).toHaveBeenCalled();
expect(mockWriteText).toHaveBeenCalledOnce();
const written = JSON.parse(mockWriteText.mock.calls[0][1]);
expect(written.plugins.entries["openclaw-mem0"].enabled).toBe(true);
expect(written.plugins.entries["openclaw-mem0"].config.apiKey).toBe(
"sk-new",
);
expect(written.plugins.entries["openclaw-mem0"].config.userId).toBe("u1");
});
it("merges into existing config preserving other data", () => {
setConfigFile({
otherSetting: "keep-me",
plugins: {
entries: {
"openclaw-mem0": {
enabled: true,
config: {
apiKey: "sk-old",
mode: "platform",
},
},
},
},
});
writePluginAuth({ baseUrl: "https://new.api.com" });
const written = JSON.parse(mockWriteText.mock.calls[0][1]);
// Existing fields preserved
expect(written.otherSetting).toBe("keep-me");
expect(written.plugins.entries["openclaw-mem0"].config.apiKey).toBe(
"sk-old",
);
expect(written.plugins.entries["openclaw-mem0"].config.mode).toBe(
"platform",
);
// New field added
expect(written.plugins.entries["openclaw-mem0"].config.baseUrl).toBe(
"https://new.api.com",
);
});
it("creates directory if missing", () => {
// File doesn't exist (readFullConfig returns {}), dir doesn't exist
mockExists.mockReturnValue(false);
writePluginAuth({ apiKey: "sk-test" });
expect(mockMkdirp).toHaveBeenCalledWith(
expect.stringContaining(".openclaw"),
0o700,
);
});
it("skips undefined values", () => {
setNoFile();
mockExists.mockReturnValue(false);
writePluginAuth({
apiKey: "sk-set",
baseUrl: undefined,
userId: undefined,
});
const written = JSON.parse(mockWriteText.mock.calls[0][1]);
const cfg = written.plugins.entries["openclaw-mem0"].config;
expect(cfg.apiKey).toBe("sk-set");
expect(cfg).not.toHaveProperty("baseUrl");
expect(cfg).not.toHaveProperty("userId");
});
});
// ---------------------------------------------------------------------------
// getBaseUrl
// ---------------------------------------------------------------------------
describe("getBaseUrl", () => {
it("returns configured URL when baseUrl is set", () => {
setConfigFile({
plugins: {
entries: {
"openclaw-mem0": {
enabled: true,
config: { baseUrl: "https://custom.example.com" },
},
},
},
});
expect(getBaseUrl()).toBe("https://custom.example.com");
});
it("returns default URL when baseUrl is not configured", () => {
setNoFile();
expect(getBaseUrl()).toBe(DEFAULT_BASE_URL);
});
});
+5 -5
View File
@@ -99,9 +99,9 @@ describe("mem0ConfigSchema.parse() — defaults", () => {
// mem0ConfigSchema.parse() — mode parsing
// ---------------------------------------------------------------------------
describe("mem0ConfigSchema.parse() — mode parsing", () => {
it('"oss" is normalized to "open-source"', () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss" });
expect(cfg.mode).toBe("open-source");
it('"oss" is not a valid mode and defaults to "platform"', () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss", apiKey: "k" });
expect(cfg.mode).toBe("platform");
});
it('"open-source" stays as "open-source"', () => {
@@ -184,8 +184,8 @@ describe("mem0ConfigSchema.parse() — needsSetup", () => {
expect(cfg.needsSetup).toBe(false);
});
it("is false when mode is open-source (no apiKey needed)", () => {
const cfg = mem0ConfigSchema.parse({ mode: "oss" });
it("is false when mode is 'open-source' via explicit string (no apiKey needed)", () => {
const cfg = mem0ConfigSchema.parse({ mode: "open-source" });
expect(cfg.needsSetup).toBe(false);
});
+376
View File
@@ -0,0 +1,376 @@
/**
* 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);
});
});
+287
View File
@@ -0,0 +1,287 @@
/**
* Tests for providers.ts — providerToBackend adapter layer.
*
* Verifies that the Backend wrapper correctly delegates to the
* underlying Mem0Provider methods with proper argument mapping.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { providerToBackend } from "../providers.ts";
// ---------------------------------------------------------------------------
// Mock provider factory
// ---------------------------------------------------------------------------
function createMockProvider() {
return {
search: vi
.fn()
.mockResolvedValue([{ id: "m1", memory: "found", score: 0.9 }]),
add: vi.fn().mockResolvedValue({
results: [{ id: "m1", event: "ADD", memory: "stored" }],
}),
get: vi
.fn()
.mockResolvedValue({ id: "m1", memory: "test", created_at: "2026-01-01" }),
getAll: vi.fn().mockResolvedValue([{ id: "m1", memory: "listed" }]),
update: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
deleteAll: vi.fn().mockResolvedValue(undefined),
history: vi.fn().mockResolvedValue([]),
};
}
const DEFAULT_USER = "test-user";
beforeEach(() => {
vi.resetAllMocks();
});
// ---------------------------------------------------------------------------
// search
// ---------------------------------------------------------------------------
describe("providerToBackend — search", () => {
it("delegates to provider.search with correct options", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
const results = await backend.search("hello world", {
topK: 10,
threshold: 0.5,
keyword: true,
rerank: true,
filters: { category: "preference" },
});
expect(provider.search).toHaveBeenCalledWith("hello world", {
user_id: DEFAULT_USER,
top_k: 10,
threshold: 0.5,
keyword_search: true,
reranking: true,
filters: { category: "preference" },
});
expect(results).toHaveLength(1);
expect((results[0] as any).id).toBe("m1");
});
it("uses default userId when opts.userId is not provided", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
await backend.search("query");
expect(provider.search).toHaveBeenCalledWith("query", {
user_id: DEFAULT_USER,
top_k: undefined,
threshold: undefined,
keyword_search: undefined,
reranking: undefined,
filters: undefined,
});
});
});
// ---------------------------------------------------------------------------
// add
// ---------------------------------------------------------------------------
describe("providerToBackend — add", () => {
it("delegates to provider.add with content as message", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
const result = await backend.add("Remember this fact");
expect(provider.add).toHaveBeenCalledWith(
[{ role: "user", content: "Remember this fact" }],
expect.objectContaining({ user_id: DEFAULT_USER }),
);
expect(result).toBeDefined();
});
it("passes messages array when provided", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
const messages = [
{ role: "user", content: "Hi" },
{ role: "assistant", content: "Hello" },
];
await backend.add(undefined, messages);
expect(provider.add).toHaveBeenCalledWith(
messages,
expect.objectContaining({ user_id: DEFAULT_USER }),
);
});
it("forwards optional add options", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
await backend.add("fact", undefined, {
runId: "run-1",
metadata: { source: "test" },
immutable: true,
infer: false,
expires: "2027-01-01",
enableGraph: true,
});
expect(provider.add).toHaveBeenCalledWith(
[{ role: "user", content: "fact" }],
expect.objectContaining({
user_id: DEFAULT_USER,
run_id: "run-1",
metadata: { source: "test" },
immutable: true,
infer: false,
expiration_date: "2027-01-01",
enable_graph: true,
}),
);
});
});
// ---------------------------------------------------------------------------
// get
// ---------------------------------------------------------------------------
describe("providerToBackend — get", () => {
it("delegates to provider.get", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
const result = await backend.get("mem-123");
expect(provider.get).toHaveBeenCalledWith("mem-123");
expect((result as any).id).toBe("m1");
});
});
// ---------------------------------------------------------------------------
// listMemories
// ---------------------------------------------------------------------------
describe("providerToBackend — listMemories", () => {
it("delegates to provider.getAll", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
const results = await backend.listMemories({ pageSize: 50 });
expect(provider.getAll).toHaveBeenCalledWith({
user_id: DEFAULT_USER,
page_size: 50,
});
expect(results).toHaveLength(1);
});
it("uses default userId when opts.userId is not provided", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
await backend.listMemories();
expect(provider.getAll).toHaveBeenCalledWith({
user_id: DEFAULT_USER,
page_size: undefined,
});
});
});
// ---------------------------------------------------------------------------
// update
// ---------------------------------------------------------------------------
describe("providerToBackend — update", () => {
it("calls provider.update with content", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
const result = await backend.update("mem-123", "updated text");
expect(provider.update).toHaveBeenCalledWith("mem-123", "updated text");
expect((result as any).id).toBe("mem-123");
expect((result as any).updated).toBe(true);
});
it("warns and skips when only metadata is provided (no content)", async () => {
const provider = createMockProvider();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const backend = providerToBackend(provider as any, DEFAULT_USER);
const result = await backend.update("mem-123", undefined, {
tag: "important",
});
expect(provider.update).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("metadata updates are not supported"),
);
expect((result as any).id).toBe("mem-123");
warnSpy.mockRestore();
});
});
// ---------------------------------------------------------------------------
// delete
// ---------------------------------------------------------------------------
describe("providerToBackend — delete", () => {
it("calls provider.delete with memoryId", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
const result = await backend.delete("mem-456");
expect(provider.delete).toHaveBeenCalledWith("mem-456");
expect((result as any).deleted).toBe("mem-456");
});
it("calls provider.deleteAll when opts.all is true", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
const result = await backend.delete(undefined, {
all: true,
userId: "custom-user",
});
expect(provider.deleteAll).toHaveBeenCalledWith("custom-user");
expect((result as any).deleted).toBe("all");
});
});
// ---------------------------------------------------------------------------
// deleteEntities (platform-only)
// ---------------------------------------------------------------------------
describe("providerToBackend — deleteEntities", () => {
it("throws platform-only error", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
await expect(
backend.deleteEntities({ userId: DEFAULT_USER }),
).rejects.toThrow("platform mode");
});
});
// ---------------------------------------------------------------------------
// status
// ---------------------------------------------------------------------------
describe("providerToBackend — status", () => {
it("returns connected: true", async () => {
const provider = createMockProvider();
const backend = providerToBackend(provider as any, DEFAULT_USER);
const result = await backend.status();
expect((result as any).connected).toBe(true);
expect((result as any).backend).toBe("oss");
});
});
+269 -87
View File
@@ -7,30 +7,26 @@
*/
import { describe, it, expect, vi } from "vitest";
import type { ToolContext } from "../tools/index.ts";
import type { ToolDeps } from "../tools/index.ts";
import { registerAllTools } from "../tools/index.ts";
import { createMemorySearchTool } from "../tools/memory-search.ts";
import { createMemoryStoreTool } from "../tools/memory-store.ts";
import { createMemoryAddTool } from "../tools/memory-add.ts";
import { createMemoryGetTool } from "../tools/memory-get.ts";
import { createMemoryDeleteTool } from "../tools/memory-delete.ts";
import { createMemoryListTool } from "../tools/memory-list.ts";
import { createMemoryUpdateTool } from "../tools/memory-update.ts";
import { createMemoryHistoryTool } from "../tools/memory-history.ts";
// ---------------------------------------------------------------------------
// Mock helper
// ---------------------------------------------------------------------------
function createMockToolContext(overrides = {}): ToolContext {
function createMockToolDeps(overrides = {}): ToolDeps {
return {
api: {
registerTool: vi.fn(),
logger: { info: vi.fn(), warn: vi.fn() },
} as any,
backend: {
get: vi.fn().mockResolvedValue({ id: "test-id", memory: "test memory" }),
delete: vi.fn().mockResolvedValue(undefined),
status: vi.fn().mockResolvedValue({ connected: true }),
deleteEntities: vi.fn().mockResolvedValue(undefined),
} as any,
cfg: {
mode: "platform",
userId: "testuser",
@@ -68,6 +64,7 @@ function createMockToolContext(overrides = {}): ToolContext {
agentUserId: vi.fn().mockReturnValue("testuser:agent:test"),
getCurrentSessionId: vi.fn().mockReturnValue(undefined),
skillsActive: false,
captureToolEvent: vi.fn(),
buildAddOptions: vi
.fn()
.mockReturnValue({ user_id: "testuser", source: "OPENCLAW" }),
@@ -84,22 +81,24 @@ function createMockToolContext(overrides = {}): ToolContext {
describe("registerAllTools", () => {
it("calls api.registerTool exactly 7 times", () => {
const ctx = createMockToolContext();
const ctx = createMockToolDeps();
registerAllTools(ctx);
expect(ctx.api.registerTool).toHaveBeenCalledTimes(7);
});
it("registers tools with the correct names", () => {
const ctx = createMockToolContext();
const ctx = createMockToolDeps();
registerAllTools(ctx);
// Tools are registered as required (single argument — no metadata object).
// The name comes from the tool definition itself (call[0]).
const names = (
ctx.api.registerTool as ReturnType<typeof vi.fn>
).mock.calls.map((call: unknown[]) => (call[1] as { name: string }).name);
).mock.calls.map((call: unknown[]) => (call[0] as { name: string }).name);
expect(names).toEqual([
"memory_search",
"memory_store",
"memory_add",
"memory_get",
"memory_list",
"memory_update",
@@ -107,6 +106,16 @@ describe("registerAllTools", () => {
"memory_history",
]);
});
it("registers tools without a second argument (required, not optional)", () => {
const ctx = createMockToolDeps();
registerAllTools(ctx);
const calls = (ctx.api.registerTool as ReturnType<typeof vi.fn>).mock.calls;
for (const call of calls) {
expect(call).toHaveLength(1);
}
});
});
// ---------------------------------------------------------------------------
@@ -116,7 +125,7 @@ describe("registerAllTools", () => {
describe("tool factory shape", () => {
const factories = [
{ fn: createMemorySearchTool, expectedName: "memory_search" },
{ fn: createMemoryStoreTool, expectedName: "memory_store" },
{ fn: createMemoryAddTool, expectedName: "memory_add" },
{ fn: createMemoryGetTool, expectedName: "memory_get" },
{ fn: createMemoryDeleteTool, expectedName: "memory_delete" },
{ fn: createMemoryListTool, expectedName: "memory_list" },
@@ -125,7 +134,7 @@ describe("tool factory shape", () => {
for (const { fn, expectedName } of factories) {
describe(expectedName, () => {
it("returns an object with name, label, description, parameters, and execute", () => {
const ctx = createMockToolContext();
const ctx = createMockToolDeps();
const tool = fn(ctx);
expect(tool.name).toBe(expectedName);
@@ -146,7 +155,7 @@ describe("tool factory shape", () => {
describe("memory_search execute", () => {
it("returns formatted results when provider returns matches", async () => {
const ctx = createMockToolContext();
const ctx = createMockToolDeps();
const tool = createMemorySearchTool(ctx);
const result = await tool.execute("call-1", {
@@ -163,7 +172,7 @@ describe("memory_search execute", () => {
});
it("returns 'no relevant memories' when provider returns empty", async () => {
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
provider: {
search: vi.fn().mockResolvedValue([]),
add: vi.fn(),
@@ -183,7 +192,7 @@ describe("memory_search execute", () => {
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
provider: {
search: vi.fn().mockRejectedValue(new Error("network failure")),
add: vi.fn(),
@@ -204,7 +213,7 @@ describe("memory_search execute", () => {
});
it("calls resolveUserId with provided agentId and userId", async () => {
const ctx = createMockToolContext();
const ctx = createMockToolDeps();
const tool = createMemorySearchTool(ctx);
await tool.execute("call-4", {
@@ -220,7 +229,7 @@ describe("memory_search execute", () => {
});
it("passes limit to buildSearchOptions", async () => {
const ctx = createMockToolContext();
const ctx = createMockToolDeps();
const tool = createMemorySearchTool(ctx);
await tool.execute("call-5", { query: "test", limit: 10 });
@@ -232,7 +241,7 @@ describe("memory_search execute", () => {
const searchMock = vi
.fn()
.mockResolvedValue([{ id: "s1", memory: "session mem", score: 0.8 }]);
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
getCurrentSessionId: vi.fn().mockReturnValue("session-abc"),
provider: {
search: searchMock,
@@ -273,7 +282,7 @@ describe("memory_search execute", () => {
{ id: "m2", memory: "session only", score: 0.7 },
]);
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
getCurrentSessionId: vi.fn().mockReturnValue("session-xyz"),
provider: {
search: searchMock,
@@ -300,13 +309,13 @@ describe("memory_search execute", () => {
});
// ---------------------------------------------------------------------------
// memory_store execute
// memory_add execute
// ---------------------------------------------------------------------------
describe("memory_store execute", () => {
describe("memory_add execute", () => {
it("calls provider.add with the text and returns stored result", async () => {
const ctx = createMockToolContext();
const tool = createMemoryStoreTool(ctx);
const ctx = createMockToolDeps();
const tool = createMemoryAddTool(ctx);
const result = await tool.execute("call-1", {
text: "User prefers dark mode",
@@ -323,8 +332,8 @@ describe("memory_store execute", () => {
});
it("returns error when no text or facts are provided", async () => {
const ctx = createMockToolContext();
const tool = createMemoryStoreTool(ctx);
const ctx = createMockToolDeps();
const tool = createMemoryAddTool(ctx);
const result = await tool.execute("call-2", {});
@@ -333,8 +342,8 @@ describe("memory_store execute", () => {
});
it("supports facts array", async () => {
const ctx = createMockToolContext();
const tool = createMemoryStoreTool(ctx);
const ctx = createMockToolDeps();
const tool = createMemoryAddTool(ctx);
const result = await tool.execute("call-3", {
facts: ["fact one", "fact two"],
@@ -350,7 +359,7 @@ describe("memory_store execute", () => {
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
provider: {
search: vi.fn().mockResolvedValue([]),
add: vi.fn().mockRejectedValue(new Error("API error")),
@@ -361,11 +370,11 @@ describe("memory_store execute", () => {
history: vi.fn(),
},
});
const tool = createMemoryStoreTool(ctx);
const tool = createMemoryAddTool(ctx);
const result = await tool.execute("call-4", { text: "test" });
expect(result.content[0].text).toContain("Memory store failed");
expect(result.content[0].text).toContain("Memory add failed");
expect(result.details.error).toContain("API error");
});
@@ -373,7 +382,7 @@ describe("memory_store execute", () => {
const addMock = vi.fn().mockResolvedValue({
results: [{ event: "ADD", memory: "stored in skills mode" }],
});
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
skillsActive: true,
provider: {
search: vi.fn().mockResolvedValue([]),
@@ -385,7 +394,7 @@ describe("memory_store execute", () => {
history: vi.fn(),
},
});
const tool = createMemoryStoreTool(ctx);
const tool = createMemoryAddTool(ctx);
const result = await tool.execute("call-5", {
text: "skills fact",
@@ -395,18 +404,17 @@ describe("memory_store execute", () => {
expect(addMock).toHaveBeenCalledOnce();
const addOpts = addMock.mock.calls[0][1];
expect(addOpts.infer).toBe(false);
expect(addOpts.source).toBe("OPENCLAW");
expect(result.details.mode).toBe("skills");
expect(result.details.category).toBe("preference");
});
it("blocks subagent sessions from storing", async () => {
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
getCurrentSessionId: vi
.fn()
.mockReturnValue("agent:main:subagent:uuid-123"),
});
const tool = createMemoryStoreTool(ctx);
const tool = createMemoryAddTool(ctx);
const result = await tool.execute("call-6", { text: "subagent fact" });
@@ -419,7 +427,7 @@ describe("memory_store execute", () => {
const addMock = vi.fn().mockResolvedValue({
results: [{ event: "ADD", memory: "stored" }],
});
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
skillsActive: false,
provider: {
search: searchMock,
@@ -431,7 +439,7 @@ describe("memory_store execute", () => {
history: vi.fn(),
},
});
const tool = createMemoryStoreTool(ctx);
const tool = createMemoryAddTool(ctx);
await tool.execute("call-7", { text: "new fact" });
@@ -447,7 +455,7 @@ describe("memory_store execute", () => {
describe("memory_get execute", () => {
it("calls provider.get with the memoryId and returns formatted result", async () => {
const ctx = createMockToolContext();
const ctx = createMockToolDeps();
const tool = createMemoryGetTool(ctx);
const result = await tool.execute("call-1", { memoryId: "test-id" });
@@ -461,7 +469,7 @@ describe("memory_get execute", () => {
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
@@ -486,11 +494,11 @@ describe("memory_get execute", () => {
// ---------------------------------------------------------------------------
describe("memory_delete execute", () => {
it("deletes by memory_id via provider.delete", async () => {
const ctx = createMockToolContext();
it("deletes by memoryId via provider.delete", async () => {
const ctx = createMockToolDeps();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-1", { memory_id: "mem-abc" });
const result = await tool.execute("call-1", { memoryId: "mem-abc" });
expect(ctx.provider!.delete).toHaveBeenCalledWith("mem-abc");
expect(result.content[0].text).toBe("Memory mem-abc deleted.");
@@ -503,7 +511,7 @@ describe("memory_delete execute", () => {
.fn()
.mockResolvedValue([{ id: "m1", memory: "match", score: 0.95 }]);
const deleteMock = vi.fn().mockResolvedValue(undefined);
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
provider: {
search: searchMock,
add: vi.fn(),
@@ -532,7 +540,7 @@ describe("memory_delete execute", () => {
{ id: "m2", memory: "candidate two", score: 0.6 },
]);
const deleteMock = vi.fn();
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
provider: {
search: searchMock,
add: vi.fn(),
@@ -557,7 +565,7 @@ describe("memory_delete execute", () => {
});
it("returns no matching memories when query yields empty results", async () => {
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
provider: {
search: vi.fn().mockResolvedValue([]),
add: vi.fn(),
@@ -577,7 +585,7 @@ describe("memory_delete execute", () => {
});
it("requires confirm:true for bulk delete (all)", async () => {
const ctx = createMockToolContext();
const ctx = createMockToolDeps();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-5", { all: true });
@@ -588,7 +596,7 @@ describe("memory_delete execute", () => {
it("performs bulk delete when all:true and confirm:true", async () => {
const deleteAllMock = vi.fn().mockResolvedValue(undefined);
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
@@ -612,59 +620,34 @@ describe("memory_delete execute", () => {
expect(result.details.action).toBe("deleted_all");
});
it("requires confirm:true for entity cascade delete", async () => {
const ctx = createMockToolContext();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-7", { entity: true });
expect(result.details.error).toBe("confirmation_required");
});
it("performs entity cascade delete when entity:true and confirm:true", async () => {
const ctx = createMockToolContext();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-8", {
entity: true,
confirm: true,
user_id: "alice",
});
expect(ctx.backend.deleteEntities).toHaveBeenCalledWith({
userId: "alice",
});
expect(result.details.action).toBe("entity_deleted");
});
it("returns error when no mode param is specified", async () => {
const ctx = createMockToolContext();
const ctx = createMockToolDeps();
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-9", {});
expect(result.content[0].text).toContain(
"Provide memory_id, query, all, or entity",
"Provide memoryId, query, or all:true",
);
expect(result.details.error).toBe("missing_param");
});
it("blocks subagent sessions from deleting", async () => {
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
getCurrentSessionId: vi
.fn()
.mockReturnValue("agent:main:subagent:uuid-456"),
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-10", { memory_id: "m1" });
const result = await tool.execute("call-10", { memoryId: "m1" });
expect(ctx.provider!.delete).not.toHaveBeenCalled();
expect(result.details.error).toBe("subagent_blocked");
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
@@ -677,7 +660,7 @@ describe("memory_delete execute", () => {
});
const tool = createMemoryDeleteTool(ctx);
const result = await tool.execute("call-11", { memory_id: "m1" });
const result = await tool.execute("call-11", { memoryId: "m1" });
expect(result.content[0].text).toContain("Memory delete failed");
expect(result.details.error).toContain("delete failed");
@@ -690,7 +673,7 @@ describe("memory_delete execute", () => {
describe("memory_list execute", () => {
it("calls provider.getAll and returns formatted list", async () => {
const ctx = createMockToolContext();
const ctx = createMockToolDeps();
const tool = createMemoryListTool(ctx);
const result = await tool.execute("call-1", {});
@@ -703,7 +686,7 @@ describe("memory_list execute", () => {
});
it("returns 'no memories stored' when provider returns empty", async () => {
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
@@ -723,7 +706,7 @@ describe("memory_list execute", () => {
});
it("handles errors gracefully", async () => {
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
@@ -743,7 +726,7 @@ describe("memory_list execute", () => {
});
it("resolves userId from agentId", async () => {
const ctx = createMockToolContext();
const ctx = createMockToolDeps();
const tool = createMemoryListTool(ctx);
await tool.execute("call-4", { agentId: "researcher" });
@@ -765,7 +748,7 @@ describe("memory_list execute", () => {
{ id: "m2", memory: "session only" },
]);
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
getCurrentSessionId: vi.fn().mockReturnValue("session-123"),
provider: {
search: vi.fn(),
@@ -790,7 +773,7 @@ describe("memory_list execute", () => {
const getAllMock = vi
.fn()
.mockResolvedValue([{ id: "s1", memory: "session mem" }]);
const ctx = createMockToolContext({
const ctx = createMockToolDeps({
getCurrentSessionId: vi.fn().mockReturnValue("sess-abc"),
provider: {
search: vi.fn(),
@@ -813,3 +796,202 @@ describe("memory_list execute", () => {
expect(result.details.count).toBe(1);
});
});
// ---------------------------------------------------------------------------
// memory_update execute
// ---------------------------------------------------------------------------
describe("memory_update execute", () => {
it("calls provider.update and returns success", async () => {
const ctx = createMockToolDeps();
const tool = createMemoryUpdateTool(ctx);
const result = await tool.execute("call-1", {
memoryId: "mem-123",
text: "Updated preference",
});
expect(ctx.provider!.update).toHaveBeenCalledWith(
"mem-123",
"Updated preference",
);
expect(result.content[0].text).toContain("Updated memory mem-123");
expect(result.content[0].text).toContain("Updated preference");
expect(result.details.action).toBe("updated");
expect(result.details.id).toBe("mem-123");
});
it("truncates long text in response", async () => {
const ctx = createMockToolDeps();
const tool = createMemoryUpdateTool(ctx);
const longText = "A".repeat(120);
const result = await tool.execute("call-2", {
memoryId: "mem-456",
text: longText,
});
expect(ctx.provider!.update).toHaveBeenCalledWith("mem-456", longText);
// The response text should contain the first 80 chars followed by "..."
expect(result.content[0].text).toContain("A".repeat(80) + "...");
expect(result.content[0].text).not.toContain("A".repeat(81));
expect(result.details.action).toBe("updated");
});
it("blocks subagent sessions", async () => {
const ctx = createMockToolDeps({
getCurrentSessionId: vi
.fn()
.mockReturnValue("agent:main:subagent:uuid-789"),
});
const tool = createMemoryUpdateTool(ctx);
const result = await tool.execute("call-3", {
memoryId: "mem-123",
text: "should not update",
});
expect(ctx.provider!.update).not.toHaveBeenCalled();
expect(result.content[0].text).toContain(
"not available in subagent sessions",
);
expect(result.details.error).toBe("subagent_blocked");
});
it("handles errors gracefully", async () => {
const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn().mockRejectedValue(new Error("update conflict")),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn(),
},
});
const tool = createMemoryUpdateTool(ctx);
const result = await tool.execute("call-4", {
memoryId: "mem-123",
text: "new text",
});
expect(result.content[0].text).toContain("Memory update failed");
expect(result.content[0].text).toContain("update conflict");
expect(result.details.error).toContain("update conflict");
});
});
// ---------------------------------------------------------------------------
// memory_history execute
// ---------------------------------------------------------------------------
describe("memory_history execute", () => {
it("returns formatted history", async () => {
const historyEntries = [
{
event: "ADD",
created_at: "2026-01-01T00:00:00Z",
old_memory: null,
new_memory: "Initial memory text",
},
{
event: "UPDATE",
created_at: "2026-01-02T00:00:00Z",
old_memory: "Initial memory text",
new_memory: "Updated memory text",
},
];
const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn().mockResolvedValue(historyEntries),
},
});
const tool = createMemoryHistoryTool(ctx);
const result = await tool.execute("call-1", { memoryId: "mem-abc" });
expect(ctx.provider!.history).toHaveBeenCalledWith("mem-abc");
expect(result.content[0].text).toContain("History for memory mem-abc");
expect(result.content[0].text).toContain("2 entries");
expect(result.content[0].text).toContain("[ADD]");
expect(result.content[0].text).toContain("[UPDATE]");
expect(result.content[0].text).toContain("Initial memory text");
expect(result.content[0].text).toContain("Updated memory text");
expect(result.content[0].text).toContain("Old: (none)");
expect(result.details.count).toBe(2);
expect(result.details.history).toEqual(historyEntries);
});
it("handles empty history", async () => {
const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn().mockResolvedValue([]),
},
});
const tool = createMemoryHistoryTool(ctx);
const result = await tool.execute("call-2", { memoryId: "mem-empty" });
expect(result.content[0].text).toBe(
"No history found for memory mem-empty.",
);
expect(result.details.count).toBe(0);
});
it("handles null history", async () => {
const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn().mockResolvedValue(null),
},
});
const tool = createMemoryHistoryTool(ctx);
const result = await tool.execute("call-3", { memoryId: "mem-null" });
expect(result.content[0].text).toBe(
"No history found for memory mem-null.",
);
expect(result.details.count).toBe(0);
});
it("handles errors gracefully", async () => {
const ctx = createMockToolDeps({
provider: {
search: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
get: vi.fn(),
history: vi.fn().mockRejectedValue(new Error("history unavailable")),
},
});
const tool = createMemoryHistoryTool(ctx);
const result = await tool.execute("call-4", { memoryId: "mem-err" });
expect(result.content[0].text).toContain("Memory history failed");
expect(result.content[0].text).toContain("history unavailable");
expect(result.details.error).toContain("history unavailable");
});
});