mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:00:43 +00:00
test: dedupe mirrored memory and deepseek tests
This commit is contained in:
@@ -10,6 +10,48 @@ import { runSingleProviderCatalog } from "../test-support/provider-model-test-he
|
||||
import deepseekPlugin from "./index.js";
|
||||
import { createDeepSeekV4ThinkingWrapper } from "./stream.js";
|
||||
|
||||
type OpenAICompletionsModel = Model<"openai-completions">;
|
||||
|
||||
type PayloadCapture = {
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function deepSeekV4Model(id: "deepseek-v4-flash" | "deepseek-v4-pro"): OpenAICompletionsModel {
|
||||
return {
|
||||
provider: "deepseek",
|
||||
id,
|
||||
name: id === "deepseek-v4-flash" ? "DeepSeek V4 Flash" : "DeepSeek V4 Pro",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 384_000,
|
||||
compat: {
|
||||
supportsUsageInStreaming: true,
|
||||
supportsReasoningEffort: true,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
} as OpenAICompletionsModel;
|
||||
}
|
||||
|
||||
function createPayloadCapturingStream(capture: PayloadCapture) {
|
||||
return (
|
||||
streamModel: OpenAICompletionsModel,
|
||||
streamContext: Context,
|
||||
options?: { onPayload?: (payload: unknown, model: unknown) => unknown },
|
||||
) => {
|
||||
capture.payload = buildOpenAICompletionsParams(streamModel, streamContext, {
|
||||
reasoning: "high",
|
||||
} as never);
|
||||
options?.onPayload?.(capture.payload, streamModel);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.end());
|
||||
return stream;
|
||||
};
|
||||
}
|
||||
|
||||
describe("deepseek provider plugin", () => {
|
||||
it("registers DeepSeek with api-key auth wizard metadata", async () => {
|
||||
const provider = await registerSingleProviderPlugin(deepseekPlugin);
|
||||
@@ -119,24 +161,8 @@ describe("deepseek provider plugin", () => {
|
||||
});
|
||||
|
||||
it("preserves replayed reasoning_content when DeepSeek V4 thinking is enabled", async () => {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const model = {
|
||||
provider: "deepseek",
|
||||
id: "deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 384_000,
|
||||
compat: {
|
||||
supportsUsageInStreaming: true,
|
||||
supportsReasoningEffort: true,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
} as Model<"openai-completions">;
|
||||
const capture: PayloadCapture = {};
|
||||
const model = deepSeekV4Model("deepseek-v4-flash");
|
||||
const context = {
|
||||
messages: [
|
||||
{ role: "user", content: "hi", timestamp: 1 },
|
||||
@@ -181,29 +207,17 @@ describe("deepseek provider plugin", () => {
|
||||
},
|
||||
],
|
||||
} as Context;
|
||||
const baseStreamFn = (
|
||||
streamModel: Model<"openai-completions">,
|
||||
streamContext: Context,
|
||||
options?: { onPayload?: (payload: unknown, model: unknown) => unknown },
|
||||
) => {
|
||||
capturedPayload = buildOpenAICompletionsParams(streamModel, streamContext, {
|
||||
reasoning: "high",
|
||||
} as never);
|
||||
options?.onPayload?.(capturedPayload, streamModel);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.end());
|
||||
return stream;
|
||||
};
|
||||
const baseStreamFn = createPayloadCapturingStream(capture);
|
||||
|
||||
const wrapThinkingHigh = createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "high");
|
||||
expect(wrapThinkingHigh).toBeDefined();
|
||||
await wrapThinkingHigh?.(model, context, {});
|
||||
|
||||
expect(capturedPayload).toMatchObject({
|
||||
expect(capture.payload).toMatchObject({
|
||||
thinking: { type: "enabled" },
|
||||
reasoning_effort: "high",
|
||||
});
|
||||
expect((capturedPayload?.messages as Array<Record<string, unknown>>)[1]).toMatchObject({
|
||||
expect((capture.payload?.messages as Array<Record<string, unknown>>)[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
reasoning_content: "call reasoning",
|
||||
tool_calls: [
|
||||
@@ -220,24 +234,8 @@ describe("deepseek provider plugin", () => {
|
||||
});
|
||||
|
||||
it("adds blank reasoning_content for replayed tool calls from non-DeepSeek turns", async () => {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const model = {
|
||||
provider: "deepseek",
|
||||
id: "deepseek-v4-pro",
|
||||
name: "DeepSeek V4 Pro",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 384_000,
|
||||
compat: {
|
||||
supportsUsageInStreaming: true,
|
||||
supportsReasoningEffort: true,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
} as Model<"openai-completions">;
|
||||
const capture: PayloadCapture = {};
|
||||
const model = deepSeekV4Model("deepseek-v4-pro");
|
||||
const context = {
|
||||
messages: [
|
||||
{ role: "user", content: "hi", timestamp: 1 },
|
||||
@@ -275,25 +273,13 @@ describe("deepseek provider plugin", () => {
|
||||
},
|
||||
],
|
||||
} as Context;
|
||||
const baseStreamFn = (
|
||||
streamModel: Model<"openai-completions">,
|
||||
streamContext: Context,
|
||||
options?: { onPayload?: (payload: unknown, model: unknown) => unknown },
|
||||
) => {
|
||||
capturedPayload = buildOpenAICompletionsParams(streamModel, streamContext, {
|
||||
reasoning: "high",
|
||||
} as never);
|
||||
options?.onPayload?.(capturedPayload, streamModel);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.end());
|
||||
return stream;
|
||||
};
|
||||
const baseStreamFn = createPayloadCapturingStream(capture);
|
||||
|
||||
const wrapThinkingHigh = createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "high");
|
||||
expect(wrapThinkingHigh).toBeDefined();
|
||||
await wrapThinkingHigh?.(model, context, {});
|
||||
|
||||
expect((capturedPayload?.messages as Array<Record<string, unknown>>)[1]).toMatchObject({
|
||||
expect((capture.payload?.messages as Array<Record<string, unknown>>)[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
reasoning_content: "",
|
||||
tool_calls: [
|
||||
@@ -310,24 +296,8 @@ describe("deepseek provider plugin", () => {
|
||||
});
|
||||
|
||||
it("adds blank reasoning_content for replayed plain assistant messages", async () => {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const model = {
|
||||
provider: "deepseek",
|
||||
id: "deepseek-v4-pro",
|
||||
name: "DeepSeek V4 Pro",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 384_000,
|
||||
compat: {
|
||||
supportsUsageInStreaming: true,
|
||||
supportsReasoningEffort: true,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
} as Model<"openai-completions">;
|
||||
const capture: PayloadCapture = {};
|
||||
const model = deepSeekV4Model("deepseek-v4-pro");
|
||||
const context = {
|
||||
messages: [
|
||||
{ role: "user", content: "hi", timestamp: 1 },
|
||||
@@ -351,25 +321,13 @@ describe("deepseek provider plugin", () => {
|
||||
{ role: "user", content: "next", timestamp: 3 },
|
||||
],
|
||||
} as Context;
|
||||
const baseStreamFn = (
|
||||
streamModel: Model<"openai-completions">,
|
||||
streamContext: Context,
|
||||
options?: { onPayload?: (payload: unknown, model: unknown) => unknown },
|
||||
) => {
|
||||
capturedPayload = buildOpenAICompletionsParams(streamModel, streamContext, {
|
||||
reasoning: "high",
|
||||
} as never);
|
||||
options?.onPayload?.(capturedPayload, streamModel);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.end());
|
||||
return stream;
|
||||
};
|
||||
const baseStreamFn = createPayloadCapturingStream(capture);
|
||||
|
||||
const wrapThinkingHigh = createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "high");
|
||||
expect(wrapThinkingHigh).toBeDefined();
|
||||
await wrapThinkingHigh?.(model, context, {});
|
||||
|
||||
expect((capturedPayload?.messages as Array<Record<string, unknown>>)[1]).toMatchObject({
|
||||
expect((capture.payload?.messages as Array<Record<string, unknown>>)[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Hello.",
|
||||
reasoning_content: "",
|
||||
@@ -377,24 +335,8 @@ describe("deepseek provider plugin", () => {
|
||||
});
|
||||
|
||||
it("strips replayed reasoning_content when DeepSeek V4 thinking is disabled", async () => {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const model = {
|
||||
provider: "deepseek",
|
||||
id: "deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 384_000,
|
||||
compat: {
|
||||
supportsUsageInStreaming: true,
|
||||
supportsReasoningEffort: true,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
} as Model<"openai-completions">;
|
||||
const capture: PayloadCapture = {};
|
||||
const model = deepSeekV4Model("deepseek-v4-flash");
|
||||
const context = {
|
||||
messages: [
|
||||
{ role: "user", content: "hi", timestamp: 1 },
|
||||
@@ -439,19 +381,7 @@ describe("deepseek provider plugin", () => {
|
||||
},
|
||||
],
|
||||
} as Context;
|
||||
const baseStreamFn = (
|
||||
streamModel: Model<"openai-completions">,
|
||||
streamContext: Context,
|
||||
options?: { onPayload?: (payload: unknown, model: unknown) => unknown },
|
||||
) => {
|
||||
capturedPayload = buildOpenAICompletionsParams(streamModel, streamContext, {
|
||||
reasoning: "high",
|
||||
} as never);
|
||||
options?.onPayload?.(capturedPayload, streamModel);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.end());
|
||||
return stream;
|
||||
};
|
||||
const baseStreamFn = createPayloadCapturingStream(capture);
|
||||
|
||||
const wrapThinkingNone = createDeepSeekV4ThinkingWrapper(
|
||||
baseStreamFn as never,
|
||||
@@ -460,9 +390,9 @@ describe("deepseek provider plugin", () => {
|
||||
expect(wrapThinkingNone).toBeDefined();
|
||||
await wrapThinkingNone?.(model, context, {});
|
||||
|
||||
expect(capturedPayload).toMatchObject({ thinking: { type: "disabled" } });
|
||||
expect(capturedPayload).not.toHaveProperty("reasoning_effort");
|
||||
expect((capturedPayload?.messages as Array<Record<string, unknown>>)[1]).not.toHaveProperty(
|
||||
expect(capture.payload).toMatchObject({ thinking: { type: "disabled" } });
|
||||
expect(capture.payload).not.toHaveProperty("reasoning_effort");
|
||||
expect((capture.payload?.messages as Array<Record<string, unknown>>)[1]).not.toHaveProperty(
|
||||
"reasoning_content",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,547 +1 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAgentWorkspaceDir } from "../../agents/agent-scope-config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveMemoryBackendConfig } from "./backend-config.js";
|
||||
import { isQmdScopeAllowed } from "./qmd-scope.js";
|
||||
|
||||
type QmdPathFixture = {
|
||||
path: string;
|
||||
name?: string;
|
||||
pattern?: string;
|
||||
};
|
||||
|
||||
const resolveComparablePath = (value: string, workspaceDir = "/workspace/root"): string =>
|
||||
path.isAbsolute(value) ? path.resolve(value) : path.resolve(workspaceDir, value);
|
||||
|
||||
function resolveCollectionNamesForAgent(cfg: OpenClawConfig, agentId: string): Set<string> {
|
||||
return new Set(
|
||||
(resolveMemoryBackendConfig({ cfg, agentId }).qmd?.collections ?? []).map(
|
||||
(collection) => collection.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCustomCollectionPathsForAgent(cfg: OpenClawConfig, agentId: string): string[] {
|
||||
return (resolveMemoryBackendConfig({ cfg, agentId }).qmd?.collections ?? [])
|
||||
.filter((collection) => collection.kind === "custom")
|
||||
.map((collection) => collection.path);
|
||||
}
|
||||
|
||||
function qmdMultiAgentConfig(paths: QmdPathFixture[]) {
|
||||
return {
|
||||
agents: {
|
||||
defaults: { workspace: "/workspace/root" },
|
||||
list: [
|
||||
{ id: "main", default: true, workspace: "/workspace/root" },
|
||||
{ id: "dev", workspace: "/workspace/dev" },
|
||||
],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: true,
|
||||
paths,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("resolveMemoryBackendConfig", () => {
|
||||
it("defaults to builtin backend when config missing", () => {
|
||||
const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } } } as OpenClawConfig;
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
expect(resolved.backend).toBe("builtin");
|
||||
expect(resolved.citations).toBe("auto");
|
||||
expect(resolved.qmd).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves qmd backend with default collections", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: "/tmp/memory-test" } },
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
expect(resolved.backend).toBe("qmd");
|
||||
expect(resolved.qmd?.collections.length).toBe(2);
|
||||
expect(resolved.qmd?.command).toBe("qmd");
|
||||
expect(resolved.qmd?.searchMode).toBe("search");
|
||||
expect(resolved.qmd?.update.intervalMs).toBeGreaterThan(0);
|
||||
expect(resolved.qmd?.update.onBoot).toBe(true);
|
||||
expect(resolved.qmd?.update.startup).toBe("off");
|
||||
expect(resolved.qmd?.update.startupDelayMs).toBe(120_000);
|
||||
expect(resolved.qmd?.update.waitForBootSync).toBe(false);
|
||||
expect(resolved.qmd?.update.commandTimeoutMs).toBe(30_000);
|
||||
expect(resolved.qmd?.update.updateTimeoutMs).toBe(120_000);
|
||||
expect(resolved.qmd?.update.embedTimeoutMs).toBe(120_000);
|
||||
const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name));
|
||||
expect(names.has("memory-root-main")).toBe(true);
|
||||
expect(names.has("memory-dir-main")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows direct and channel sessions in the default qmd scope", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: "/tmp/memory-test" } },
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
|
||||
expect(isQmdScopeAllowed(resolved.qmd?.scope, "agent:main:discord:direct:user-123")).toBe(true);
|
||||
expect(isQmdScopeAllowed(resolved.qmd?.scope, "agent:main:discord:channel:chan-123")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isQmdScopeAllowed(resolved.qmd?.scope, "agent:main:discord:group:group-123")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses quoted qmd command paths", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: "/tmp/memory-test" } },
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
command: '"/Applications/QMD Tools/qmd" --flag',
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
expect(resolved.qmd?.command).toBe("/Applications/QMD Tools/qmd");
|
||||
});
|
||||
|
||||
it("preserves explicit homebrew qmd paths for service environments", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: "/tmp/memory-test" } },
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
command: "/opt/homebrew/bin/qmd",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
expect(resolved.qmd?.command).toBe("/opt/homebrew/bin/qmd");
|
||||
});
|
||||
|
||||
it("resolves custom paths relative to workspace", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: "/workspace/root" },
|
||||
list: [{ id: "main", workspace: "/workspace/root" }],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
paths: [
|
||||
{
|
||||
path: "notes",
|
||||
name: "custom-notes",
|
||||
pattern: "**/*.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
const custom = resolved.qmd?.collections.find((c) => c.name.startsWith("custom-notes"));
|
||||
expect(custom).toBeDefined();
|
||||
const workspaceRoot = resolveAgentWorkspaceDir(cfg, "main");
|
||||
expect(custom?.path).toBe(path.resolve(workspaceRoot, "notes"));
|
||||
});
|
||||
|
||||
it("scopes qmd collection names per agent", () => {
|
||||
const cfg = qmdMultiAgentConfig([{ path: "notes", name: "workspace", pattern: "**/*.md" }]);
|
||||
const mainNames = resolveCollectionNamesForAgent(cfg, "main");
|
||||
const devNames = resolveCollectionNamesForAgent(cfg, "dev");
|
||||
expect(mainNames.has("memory-dir-main")).toBe(true);
|
||||
expect(devNames.has("memory-dir-dev")).toBe(true);
|
||||
expect(mainNames.has("workspace-main")).toBe(true);
|
||||
expect(devNames.has("workspace-dev")).toBe(true);
|
||||
});
|
||||
|
||||
it("merges default and per-agent qmd extra collections", () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/workspace/root",
|
||||
memorySearch: {
|
||||
qmd: {
|
||||
extraCollections: [
|
||||
{
|
||||
path: "/shared/team-notes",
|
||||
name: "team-notes",
|
||||
pattern: "**/*.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
workspace: "/workspace/root",
|
||||
memorySearch: {
|
||||
qmd: {
|
||||
extraCollections: [
|
||||
{
|
||||
path: "notes",
|
||||
name: "notes",
|
||||
pattern: "**/*.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const names = resolveCollectionNamesForAgent(cfg, "main");
|
||||
expect(names.has("team-notes")).toBe(true);
|
||||
expect(names.has("notes-main")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves explicit custom collection names for paths outside the workspace", () => {
|
||||
const cfg = qmdMultiAgentConfig([
|
||||
{ path: "/shared/notion-mirror", name: "notion-mirror", pattern: "**/*.md" },
|
||||
]);
|
||||
const mainNames = resolveCollectionNamesForAgent(cfg, "main");
|
||||
const devNames = resolveCollectionNamesForAgent(cfg, "dev");
|
||||
expect(mainNames.has("memory-dir-main")).toBe(true);
|
||||
expect(devNames.has("memory-dir-dev")).toBe(true);
|
||||
expect(mainNames.has("notion-mirror")).toBe(true);
|
||||
expect(devNames.has("notion-mirror")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps symlinked workspace paths agent-scoped when deciding custom collection names", async () => {
|
||||
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-backend-config-"));
|
||||
const workspaceDir = path.join(tmpRoot, "workspace");
|
||||
const workspaceAliasDir = path.join(tmpRoot, "workspace-alias");
|
||||
try {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.symlink(workspaceDir, workspaceAliasDir);
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: workspaceDir },
|
||||
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
paths: [{ path: workspaceAliasDir, name: "workspace", pattern: "**/*.md" }],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const names = resolveCollectionNamesForAgent(cfg, "main");
|
||||
expect(names.has("workspace-main")).toBe(true);
|
||||
expect(names.has("workspace")).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps unresolved child paths under a symlinked workspace agent-scoped", async () => {
|
||||
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-backend-config-"));
|
||||
const realRootDir = path.join(tmpRoot, "real-root");
|
||||
const aliasRootDir = path.join(tmpRoot, "alias-root");
|
||||
const workspaceDir = path.join(realRootDir, "workspace");
|
||||
const workspaceAliasDir = path.join(aliasRootDir, "workspace");
|
||||
try {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.symlink(realRootDir, aliasRootDir);
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: { workspace: workspaceDir },
|
||||
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
includeDefaultMemory: false,
|
||||
paths: [
|
||||
{ path: path.join(workspaceAliasDir, "notes"), name: "notes", pattern: "**/*.md" },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const names = resolveCollectionNamesForAgent(cfg, "main");
|
||||
expect(names.has("notes-main")).toBe(true);
|
||||
expect(names.has("notes")).toBe(false);
|
||||
} finally {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves qmd update timeout overrides", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: "/tmp/memory-test" } },
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
update: {
|
||||
waitForBootSync: true,
|
||||
commandTimeoutMs: 12_000,
|
||||
updateTimeoutMs: 480_000,
|
||||
embedTimeoutMs: 360_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
expect(resolved.qmd?.update.waitForBootSync).toBe(true);
|
||||
expect(resolved.qmd?.update.commandTimeoutMs).toBe(12_000);
|
||||
expect(resolved.qmd?.update.updateTimeoutMs).toBe(480_000);
|
||||
expect(resolved.qmd?.update.embedTimeoutMs).toBe(360_000);
|
||||
});
|
||||
|
||||
it("resolves qmd startup refresh overrides", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: "/tmp/memory-test" } },
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
update: {
|
||||
startup: "idle",
|
||||
startupDelayMs: 45_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
expect(resolved.qmd?.update.startup).toBe("idle");
|
||||
expect(resolved.qmd?.update.startupDelayMs).toBe(45_000);
|
||||
expect(resolved.qmd?.update.onBoot).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves qmd search mode override", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: "/tmp/memory-test" } },
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
searchMode: "vsearch",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
expect(resolved.qmd?.searchMode).toBe("vsearch");
|
||||
});
|
||||
|
||||
it("resolves qmd mcporter search tool override", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { workspace: "/tmp/memory-test" } },
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
searchMode: "query",
|
||||
searchTool: " hybrid_search ",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
expect(resolved.qmd?.searchMode).toBe("query");
|
||||
expect(resolved.qmd?.searchTool).toBe("hybrid_search");
|
||||
});
|
||||
});
|
||||
|
||||
describe("memorySearch.extraPaths integration", () => {
|
||||
it("maps agents.defaults.memorySearch.extraPaths to QMD collections", () => {
|
||||
const cfg = {
|
||||
memory: { backend: "qmd" },
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/workspace/root",
|
||||
memorySearch: {
|
||||
extraPaths: ["/home/user/docs", "/home/user/vault"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = resolveMemoryBackendConfig({ cfg, agentId: "test-agent" });
|
||||
expect(result.backend).toBe("qmd");
|
||||
const customCollections = (result.qmd?.collections ?? []).filter(
|
||||
(collection) => collection.kind === "custom",
|
||||
);
|
||||
expect(customCollections.length).toBeGreaterThanOrEqual(2);
|
||||
expect(customCollections.map((collection) => collection.path)).toEqual(
|
||||
expect.arrayContaining([
|
||||
resolveComparablePath("/home/user/docs"),
|
||||
resolveComparablePath("/home/user/vault"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("merges default and per-agent memorySearch.extraPaths for QMD collections", () => {
|
||||
const cfg = {
|
||||
memory: { backend: "qmd" },
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/workspace/root",
|
||||
memorySearch: {
|
||||
extraPaths: ["/default/path"],
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "my-agent",
|
||||
memorySearch: {
|
||||
extraPaths: ["/agent/specific/path"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" });
|
||||
expect(result.backend).toBe("qmd");
|
||||
const paths = resolveCustomCollectionPathsForAgent(cfg, "my-agent");
|
||||
expect(paths).toContain(resolveComparablePath("/agent/specific/path"));
|
||||
expect(paths).toContain(resolveComparablePath("/default/path"));
|
||||
});
|
||||
|
||||
it("falls back to defaults when agent has no overrides", () => {
|
||||
const cfg = {
|
||||
memory: { backend: "qmd" },
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/workspace/root",
|
||||
memorySearch: {
|
||||
extraPaths: ["/default/path"],
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "other-agent",
|
||||
memorySearch: {
|
||||
extraPaths: ["/other/path"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" });
|
||||
expect(result.backend).toBe("qmd");
|
||||
const paths = resolveCustomCollectionPathsForAgent(cfg, "my-agent");
|
||||
expect(paths).toContain(resolveComparablePath("/default/path"));
|
||||
});
|
||||
|
||||
it("deduplicates merged memorySearch.extraPaths for QMD collections", () => {
|
||||
const cfg = {
|
||||
memory: { backend: "qmd" },
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/workspace/root",
|
||||
memorySearch: {
|
||||
extraPaths: ["/shared/path", " /shared/path "],
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "my-agent",
|
||||
memorySearch: {
|
||||
extraPaths: ["/shared/path", "/agent-only"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const paths = resolveCustomCollectionPathsForAgent(cfg, "my-agent");
|
||||
|
||||
expect(
|
||||
paths.filter((collectionPath) => collectionPath === resolveComparablePath("/shared/path")),
|
||||
).toHaveLength(1);
|
||||
expect(paths).toContain(resolveComparablePath("/agent-only"));
|
||||
});
|
||||
|
||||
it("keeps unnamed extra paths agent-scoped even when they resolve outside the workspace", () => {
|
||||
const cfg = {
|
||||
memory: { backend: "qmd" },
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/workspace/root",
|
||||
memorySearch: {
|
||||
extraPaths: ["/shared/path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" });
|
||||
const customCollections = (result.qmd?.collections ?? []).filter(
|
||||
(collection) => collection.kind === "custom",
|
||||
);
|
||||
expect(customCollections.map((collection) => collection.name)).toContain("custom-1-my-agent");
|
||||
});
|
||||
|
||||
it("matches per-agent memorySearch.extraPaths using normalized agent ids", () => {
|
||||
const cfg = {
|
||||
memory: { backend: "qmd" },
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/workspace/root",
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "My-Agent",
|
||||
memorySearch: {
|
||||
extraPaths: ["/agent/mixed-case"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" });
|
||||
const customCollections = (result.qmd?.collections ?? []).filter(
|
||||
(collection) => collection.kind === "custom",
|
||||
);
|
||||
|
||||
expect(customCollections.map((collection) => collection.path)).toContain(
|
||||
resolveComparablePath("/agent/mixed-case"),
|
||||
);
|
||||
});
|
||||
|
||||
it("deduplicates identical roots shared by memory.qmd.paths and memorySearch.extraPaths", () => {
|
||||
const cfg = {
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
paths: [{ path: "docs", pattern: "**/*.md", name: "workspace-docs" }],
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "/workspace/root",
|
||||
memorySearch: {
|
||||
extraPaths: ["./docs"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = resolveMemoryBackendConfig({ cfg, agentId: "main" });
|
||||
const customCollections = (result.qmd?.collections ?? []).filter(
|
||||
(collection) => collection.kind === "custom",
|
||||
);
|
||||
const docsCollections = customCollections.filter(
|
||||
(collection) =>
|
||||
collection.path === resolveComparablePath("./docs") && collection.pattern === "**/*.md",
|
||||
);
|
||||
|
||||
expect(docsCollections).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
import "../../../packages/memory-host-sdk/src/host/backend-config.test.js";
|
||||
|
||||
@@ -1,59 +1 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const postJsonMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./post-json.js", () => ({
|
||||
postJson: postJsonMock,
|
||||
}));
|
||||
|
||||
type EmbeddingsRemoteFetchModule = typeof import("./embeddings-remote-fetch.js");
|
||||
|
||||
let fetchRemoteEmbeddingVectors: EmbeddingsRemoteFetchModule["fetchRemoteEmbeddingVectors"];
|
||||
|
||||
describe("fetchRemoteEmbeddingVectors", () => {
|
||||
beforeAll(async () => {
|
||||
({ fetchRemoteEmbeddingVectors } = await import("./embeddings-remote-fetch.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
postJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it("maps remote embedding response data to vectors", async () => {
|
||||
postJsonMock.mockImplementationOnce(async (params) => {
|
||||
return await params.parse({
|
||||
data: [{ embedding: [0.1, 0.2] }, {}, { embedding: [0.3] }],
|
||||
});
|
||||
});
|
||||
|
||||
const vectors = await fetchRemoteEmbeddingVectors({
|
||||
url: "https://memory.example/v1/embeddings",
|
||||
headers: { Authorization: "Bearer test" },
|
||||
body: { input: ["one", "two", "three"] },
|
||||
errorPrefix: "embedding fetch failed",
|
||||
});
|
||||
|
||||
expect(vectors).toEqual([[0.1, 0.2], [], [0.3]]);
|
||||
expect(postJsonMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://memory.example/v1/embeddings",
|
||||
headers: { Authorization: "Bearer test" },
|
||||
body: { input: ["one", "two", "three"] },
|
||||
errorPrefix: "embedding fetch failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws a status-rich error on non-ok responses", async () => {
|
||||
postJsonMock.mockRejectedValueOnce(new Error("embedding fetch failed: 403 forbidden"));
|
||||
|
||||
await expect(
|
||||
fetchRemoteEmbeddingVectors({
|
||||
url: "https://memory.example/v1/embeddings",
|
||||
headers: {},
|
||||
body: { input: ["one"] },
|
||||
errorPrefix: "embedding fetch failed",
|
||||
}),
|
||||
).rejects.toThrow("embedding fetch failed: 403 forbidden");
|
||||
});
|
||||
});
|
||||
import "../../../packages/memory-host-sdk/src/host/embeddings-remote-fetch.test.js";
|
||||
|
||||
@@ -1,69 +1 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./remote-http.js", () => ({
|
||||
withRemoteHttpResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
const { postJson } = await import("./post-json.js");
|
||||
const { withRemoteHttpResponse } = await import("./remote-http.js");
|
||||
const remoteHttpMock = vi.mocked(withRemoteHttpResponse);
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => payload,
|
||||
text: async () => JSON.stringify(payload),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function textResponse(body: string, status: number): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => JSON.parse(body) as unknown,
|
||||
text: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe("postJson", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("parses JSON payload on successful response", async () => {
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
return await params.onResponse(jsonResponse({ data: [{ embedding: [1, 2] }] }));
|
||||
});
|
||||
|
||||
const result = await postJson({
|
||||
url: "https://memory.example/v1/post",
|
||||
headers: { Authorization: "Bearer test" },
|
||||
body: { input: ["x"] },
|
||||
errorPrefix: "post failed",
|
||||
parse: (payload) => payload,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ data: [{ embedding: [1, 2] }] });
|
||||
});
|
||||
|
||||
it("attaches status to thrown error when requested", async () => {
|
||||
remoteHttpMock.mockImplementationOnce(async (params) => {
|
||||
return await params.onResponse(textResponse("bad gateway", 502));
|
||||
});
|
||||
|
||||
await expect(
|
||||
postJson({
|
||||
url: "https://memory.example/v1/post",
|
||||
headers: {},
|
||||
body: {},
|
||||
errorPrefix: "post failed",
|
||||
attachStatus: true,
|
||||
parse: () => ({}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringContaining("post failed: 502 bad gateway"),
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
});
|
||||
import "../../../packages/memory-host-sdk/src/host/post-json.test.js";
|
||||
|
||||
Reference in New Issue
Block a user