Files
openclaw/src/commands/doctor-memory-search.test.ts
Vincent Koc 0609bf8581 feat(memory): harden dreaming and multilingual memory promotion (#60697)
* feat(memory): add recall audit and doctor repair flow

* refactor(memory): rename symbolic scoring and harden dreaming

* feat(memory): add multilingual concept vocabulary

* docs(changelog): note dreaming memory follow-up

* docs(changelog): shorten dreaming follow-up entry

* fix(memory): address review follow-ups

* chore(skills): tighten security triage trust model

* Update CHANGELOG.md
2026-04-04 15:48:13 +09:00

541 lines
17 KiB
TypeScript

import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { checkQmdBinaryAvailability as checkQmdBinaryAvailabilityFn } from "../plugin-sdk/memory-core-host-engine-qmd.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
const note = vi.hoisted(() => vi.fn());
const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "agent-default"));
const resolveAgentDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default"));
const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default/workspace"));
const resolveMemorySearchConfig = vi.hoisted(() => vi.fn());
const resolveApiKeyForProvider = vi.hoisted(() => vi.fn());
const resolveActiveMemoryBackendConfig = vi.hoisted(() => vi.fn());
const getActiveMemorySearchManager = vi.hoisted(() => vi.fn());
type CheckQmdBinaryAvailability = typeof checkQmdBinaryAvailabilityFn;
const checkQmdBinaryAvailability = vi.hoisted(() =>
vi.fn<CheckQmdBinaryAvailability>(async () => ({ available: true })),
);
const auditShortTermPromotionArtifacts = vi.hoisted(() => vi.fn());
const repairShortTermPromotionArtifacts = vi.hoisted(() => vi.fn());
vi.mock("../terminal/note.js", () => ({
note,
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId,
resolveAgentDir,
resolveAgentWorkspaceDir,
}));
vi.mock("../agents/memory-search.js", () => ({
resolveMemorySearchConfig,
}));
vi.mock("../agents/model-auth.js", () => ({
resolveApiKeyForProvider,
}));
vi.mock("../plugins/memory-runtime.js", () => ({
resolveActiveMemoryBackendConfig,
getActiveMemorySearchManager,
}));
vi.mock("../plugin-sdk/memory-core-host-engine-qmd.js", () => ({
checkQmdBinaryAvailability,
}));
vi.mock("../plugin-sdk/memory-core-engine-runtime.js", () => ({
auditShortTermPromotionArtifacts,
repairShortTermPromotionArtifacts,
getBuiltinMemoryEmbeddingProviderDoctorMetadata: vi.fn((provider: string) => {
if (provider === "gemini") {
return { authProviderId: "google", envVars: ["GEMINI_API_KEY"] };
}
if (provider === "mistral") {
return { authProviderId: "mistral", envVars: ["MISTRAL_API_KEY"] };
}
if (provider === "openai") {
return { authProviderId: "openai", envVars: ["OPENAI_API_KEY"] };
}
return null;
}),
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata: vi.fn(() => [
{
providerId: "openai",
authProviderId: "openai",
envVars: ["OPENAI_API_KEY"],
transport: "remote",
},
{ providerId: "local", authProviderId: "local", envVars: [], transport: "local" },
]),
}));
import { noteMemorySearchHealth } from "./doctor-memory-search.js";
import { maybeRepairMemoryRecallHealth, noteMemoryRecallHealth } from "./doctor-memory-search.js";
import { detectLegacyWorkspaceDirs } from "./doctor-workspace.js";
describe("noteMemorySearchHealth", () => {
const cfg = {} as OpenClawConfig;
async function expectNoWarningWithConfiguredRemoteApiKey(provider: string) {
resolveMemorySearchConfig.mockReturnValue({
provider,
local: {},
remote: { apiKey: "from-config" },
});
await noteMemorySearchHealth(cfg, {});
expect(note).not.toHaveBeenCalled();
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
}
beforeEach(() => {
note.mockClear();
resolveDefaultAgentId.mockClear();
resolveAgentDir.mockClear();
resolveAgentWorkspaceDir.mockClear();
resolveMemorySearchConfig.mockReset();
resolveApiKeyForProvider.mockReset();
resolveApiKeyForProvider.mockRejectedValue(new Error("missing key"));
resolveActiveMemoryBackendConfig.mockReset();
resolveActiveMemoryBackendConfig.mockReturnValue({ backend: "builtin", citations: "auto" });
getActiveMemorySearchManager.mockReset();
getActiveMemorySearchManager.mockResolvedValue({
manager: {
status: () => ({ workspaceDir: "/tmp/agent-default/workspace", backend: "builtin" }),
close: vi.fn(async () => {}),
},
});
checkQmdBinaryAvailability.mockReset();
checkQmdBinaryAvailability.mockResolvedValue({ available: true });
auditShortTermPromotionArtifacts.mockReset();
auditShortTermPromotionArtifacts.mockResolvedValue({
storePath: "/tmp/agent-default/workspace/memory/.dreams/short-term-recall.json",
lockPath: "/tmp/agent-default/workspace/memory/.dreams/short-term-promotion.lock",
exists: true,
entryCount: 1,
promotedCount: 0,
spacedEntryCount: 0,
conceptTaggedEntryCount: 1,
invalidEntryCount: 0,
issues: [],
});
repairShortTermPromotionArtifacts.mockReset();
repairShortTermPromotionArtifacts.mockResolvedValue({
changed: false,
removedInvalidEntries: 0,
rewroteStore: false,
removedStaleLock: false,
});
});
it("does not warn when local provider is set with no explicit modelPath (default model fallback)", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "local",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {});
expect(note).not.toHaveBeenCalled();
});
it("warns when local provider with default model but gateway probe reports not ready", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "local",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {
gatewayMemoryProbe: { checked: true, ready: false, error: "node-llama-cpp not installed" },
});
expect(note).toHaveBeenCalledTimes(1);
const message = String(note.mock.calls[0]?.[0] ?? "");
expect(message).toContain("gateway reports local embeddings are not ready");
expect(message).toContain("node-llama-cpp not installed");
});
it("does not warn when local provider with default model and gateway probe is ready", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "local",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {
gatewayMemoryProbe: { checked: true, ready: true },
});
expect(note).not.toHaveBeenCalled();
});
it("does not warn when local provider has an explicit hf: modelPath", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "local",
local: { modelPath: "hf:some-org/some-model-GGUF/model.gguf" },
remote: {},
});
await noteMemorySearchHealth(cfg, {});
expect(note).not.toHaveBeenCalled();
});
it("does not warn when QMD backend is active", async () => {
resolveActiveMemoryBackendConfig.mockReturnValue({
backend: "qmd",
citations: "auto",
qmd: { command: "qmd" },
});
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {});
expect(note).not.toHaveBeenCalled();
expect(checkQmdBinaryAvailability).toHaveBeenCalledWith({
command: "qmd",
env: process.env,
cwd: "/tmp/agent-default/workspace",
});
});
it("warns when QMD backend is active but the qmd binary is unavailable", async () => {
resolveActiveMemoryBackendConfig.mockReturnValue({
backend: "qmd",
citations: "auto",
qmd: { command: "qmd" },
});
checkQmdBinaryAvailability.mockResolvedValueOnce({
available: false,
error: "spawn qmd ENOENT",
});
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {});
expect(note).toHaveBeenCalledTimes(1);
const message = String(note.mock.calls[0]?.[0] ?? "");
expect(message).toContain("QMD memory backend is configured");
expect(message).toContain("spawn qmd ENOENT");
expect(message).toContain("npm install -g @tobilu/qmd");
});
it("does not warn when remote apiKey is configured for explicit provider", async () => {
await expectNoWarningWithConfiguredRemoteApiKey("openai");
});
it("treats SecretRef remote apiKey as configured for explicit provider", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "openai",
local: {},
remote: {
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
});
await noteMemorySearchHealth(cfg, {});
expect(note).not.toHaveBeenCalled();
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
});
it("does not warn in auto mode when remote apiKey is configured", async () => {
await expectNoWarningWithConfiguredRemoteApiKey("auto");
});
it("treats SecretRef remote apiKey as configured in auto mode", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
local: {},
remote: {
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
},
});
await noteMemorySearchHealth(cfg, {});
expect(note).not.toHaveBeenCalled();
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
});
it("resolves provider auth from the default agent directory", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "gemini",
local: {},
remote: {},
});
resolveApiKeyForProvider.mockResolvedValue({
apiKey: "k",
source: "env: GEMINI_API_KEY",
mode: "api-key",
});
await noteMemorySearchHealth(cfg, {});
expect(resolveApiKeyForProvider).toHaveBeenCalledWith({
provider: "google",
cfg,
agentDir: "/tmp/agent-default",
});
expect(note).not.toHaveBeenCalled();
});
it("resolves mistral auth for explicit mistral embedding provider", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "mistral",
local: {},
remote: {},
});
resolveApiKeyForProvider.mockResolvedValue({
apiKey: "k",
source: "env: MISTRAL_API_KEY",
mode: "api-key",
});
await noteMemorySearchHealth(cfg);
expect(resolveApiKeyForProvider).toHaveBeenCalledWith({
provider: "mistral",
cfg,
agentDir: "/tmp/agent-default",
});
expect(note).not.toHaveBeenCalled();
});
it("notes when gateway probe reports embeddings ready and CLI API key is missing", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "gemini",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {
gatewayMemoryProbe: { checked: true, ready: true },
});
const message = note.mock.calls[0]?.[0] as string;
expect(message).toContain("reports memory embeddings are ready");
});
it("uses model configure hint when gateway probe is unavailable and API key is missing", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "gemini",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {
gatewayMemoryProbe: {
checked: true,
ready: false,
error: "gateway memory probe unavailable: timeout",
},
});
const message = note.mock.calls[0]?.[0] as string;
expect(message).toContain("Gateway memory probe for default agent is not ready");
expect(message).toContain("openclaw configure --section model");
expect(message).not.toContain("openclaw auth add --provider");
});
it("warns in auto mode when no local modelPath and no API keys are configured", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg);
// In auto mode, canAutoSelectLocal requires an explicit local file path.
// DEFAULT_LOCAL_MODEL fallback does NOT apply to auto — only to explicit
// provider: "local". So with no local file and no API keys, warn.
expect(note).toHaveBeenCalledTimes(1);
const message = String(note.mock.calls[0]?.[0] ?? "");
expect(message).toContain("needs at least one embedding provider");
expect(message).toContain("openclaw configure --section model");
});
it("still warns in auto mode when only ollama credentials exist", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
local: {},
remote: {},
});
resolveApiKeyForProvider.mockImplementation(async ({ provider }: { provider: string }) => {
if (provider === "ollama") {
return {
apiKey: "ollama-local", // pragma: allowlist secret
source: "env: OLLAMA_API_KEY",
mode: "api-key",
};
}
throw new Error("missing key");
});
await noteMemorySearchHealth(cfg);
expect(note).toHaveBeenCalledTimes(1);
const providerCalls = resolveApiKeyForProvider.mock.calls as Array<[{ provider: string }]>;
const providersChecked = providerCalls.map(([arg]) => arg.provider);
expect(providersChecked).toEqual(["openai", "google", "voyage", "mistral"]);
});
it("uses runtime-derived env var hints for explicit providers", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "gemini",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg);
const message = String(note.mock.calls[0]?.[0] ?? "");
expect(message).toContain("GEMINI_API_KEY");
expect(message).toContain('provider is set to "gemini"');
});
it("uses runtime-derived env var hints in auto mode", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg);
const message = String(note.mock.calls[0]?.[0] ?? "");
expect(message).toContain("OPENAI_API_KEY");
expect(message).toContain("GEMINI_API_KEY");
expect(message).toContain("GOOGLE_API_KEY");
expect(message).toContain("VOYAGE_API_KEY");
expect(message).toContain("MISTRAL_API_KEY");
});
});
describe("memory recall doctor integration", () => {
const cfg = {} as OpenClawConfig;
function createPrompter(overrides: Partial<DoctorPrompter> = {}): DoctorPrompter {
return {
confirm: vi.fn(async () => true),
confirmAutoFix: vi.fn(async () => true),
confirmAggressiveAutoFix: vi.fn(async () => true),
confirmRuntimeRepair: vi.fn(async () => true),
select: vi.fn(async (_params, fallback) => fallback),
shouldRepair: true,
shouldForce: false,
repairMode: {
shouldRepair: true,
shouldForce: false,
nonInteractive: false,
canPrompt: true,
updateInProgress: false,
},
...overrides,
};
}
it("notes recall-store audit problems with doctor guidance", async () => {
auditShortTermPromotionArtifacts.mockResolvedValueOnce({
storePath: "/tmp/agent-default/workspace/memory/.dreams/short-term-recall.json",
lockPath: "/tmp/agent-default/workspace/memory/.dreams/short-term-promotion.lock",
exists: true,
entryCount: 12,
promotedCount: 4,
spacedEntryCount: 2,
conceptTaggedEntryCount: 10,
invalidEntryCount: 1,
issues: [
{
severity: "warn",
code: "recall-store-invalid",
message: "Short-term recall store contains 1 invalid entry.",
fixable: true,
},
{
severity: "warn",
code: "recall-lock-stale",
message: "Short-term promotion lock appears stale.",
fixable: true,
},
],
});
await noteMemoryRecallHealth(cfg);
expect(auditShortTermPromotionArtifacts).toHaveBeenCalledWith({
workspaceDir: "/tmp/agent-default/workspace",
qmd: undefined,
});
expect(note).toHaveBeenCalledTimes(1);
const message = String(note.mock.calls[0]?.[0] ?? "");
expect(message).toContain("Memory recall artifacts need attention:");
expect(message).toContain("doctor --fix");
expect(message).toContain("memory status --fix");
});
it("runs memory recall repair during doctor --fix", async () => {
auditShortTermPromotionArtifacts.mockResolvedValueOnce({
storePath: "/tmp/agent-default/workspace/memory/.dreams/short-term-recall.json",
lockPath: "/tmp/agent-default/workspace/memory/.dreams/short-term-promotion.lock",
exists: true,
entryCount: 12,
promotedCount: 4,
spacedEntryCount: 2,
conceptTaggedEntryCount: 10,
invalidEntryCount: 1,
issues: [
{
severity: "warn",
code: "recall-store-invalid",
message: "Short-term recall store contains 1 invalid entry.",
fixable: true,
},
],
});
repairShortTermPromotionArtifacts.mockResolvedValueOnce({
changed: true,
removedInvalidEntries: 1,
rewroteStore: true,
removedStaleLock: true,
});
const prompter = createPrompter();
await maybeRepairMemoryRecallHealth({ cfg, prompter });
expect(prompter.confirmRuntimeRepair).toHaveBeenCalled();
expect(repairShortTermPromotionArtifacts).toHaveBeenCalledWith({
workspaceDir: "/tmp/agent-default/workspace",
});
expect(note).toHaveBeenCalledTimes(1);
const message = String(note.mock.calls[0]?.[0] ?? "");
expect(message).toContain("Memory recall artifacts repaired:");
expect(message).toContain("rewrote recall store");
expect(message).toContain("removed stale promotion lock");
});
});
describe("detectLegacyWorkspaceDirs", () => {
it("returns active workspace and no legacy dirs", () => {
const workspaceDir = "/home/user/openclaw";
const detection = detectLegacyWorkspaceDirs({ workspaceDir });
expect(detection.activeWorkspace).toBe(path.resolve(workspaceDir));
expect(detection.legacyDirs).toEqual([]);
});
});