mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 01:20:21 +00:00
* fix(doctor): resolve false positive for local memory search when no explicit modelPath When memorySearch.provider is 'local' (or 'auto') and no explicit local.modelPath is configured, the runtime auto-resolves to DEFAULT_LOCAL_MODEL (embeddinggemma-300m via HuggingFace). However, the doctor's hasLocalEmbeddings() check only inspected the config value and returned false when modelPath was empty, triggering a misleading warning. Fix: fall back to DEFAULT_LOCAL_MODEL in hasLocalEmbeddings(), matching the runtime behavior in createLocalEmbeddingProvider(). Closes #31998 * fix: scope DEFAULT_LOCAL_MODEL fallback to explicit provider:local only Address review feedback: canAutoSelectLocal() in the runtime skips local for empty/hf: model paths in auto mode. The DEFAULT_LOCAL_MODEL fallback should only apply when provider is explicitly 'local', not when provider is 'auto' — otherwise users with no local file and no API keys would get a clean doctor report but no working embeddings. Add useDefaultFallback parameter to hasLocalEmbeddings() to distinguish the two code paths. * fix: preserve gateway probe warning for local provider with default model When hasLocalEmbeddings returns true via DEFAULT_LOCAL_MODEL fallback, also check the gateway memory probe if available. If the probe reports not-ready (e.g. node-llama-cpp missing or model download failed), emit a warning instead of silently reporting healthy. Addresses review feedback about bypassing probe-based validation. * fix: add changelog attribution for doctor local fallback fix (#32014) (thanks @adhishthite) --------- Co-authored-by: Adhish <adhishthite@Adhishs-MacBook-Pro.local> Co-authored-by: Peter Steinberger <steipete@gmail.com>
248 lines
7.4 KiB
TypeScript
248 lines
7.4 KiB
TypeScript
import path from "node:path";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../config/config.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 resolveMemorySearchConfig = vi.hoisted(() => vi.fn());
|
|
const resolveApiKeyForProvider = vi.hoisted(() => vi.fn());
|
|
const resolveMemoryBackendConfig = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../terminal/note.js", () => ({
|
|
note,
|
|
}));
|
|
|
|
vi.mock("../agents/agent-scope.js", () => ({
|
|
resolveDefaultAgentId,
|
|
resolveAgentDir,
|
|
}));
|
|
|
|
vi.mock("../agents/memory-search.js", () => ({
|
|
resolveMemorySearchConfig,
|
|
}));
|
|
|
|
vi.mock("../agents/model-auth.js", () => ({
|
|
resolveApiKeyForProvider,
|
|
}));
|
|
|
|
vi.mock("../memory/backend-config.js", () => ({
|
|
resolveMemoryBackendConfig,
|
|
}));
|
|
|
|
import { noteMemorySearchHealth } 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();
|
|
resolveMemorySearchConfig.mockReset();
|
|
resolveApiKeyForProvider.mockReset();
|
|
resolveApiKeyForProvider.mockRejectedValue(new Error("missing key"));
|
|
resolveMemoryBackendConfig.mockReset();
|
|
resolveMemoryBackendConfig.mockReturnValue({ backend: "builtin", citations: "auto" });
|
|
});
|
|
|
|
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 () => {
|
|
resolveMemoryBackendConfig.mockReturnValue({
|
|
backend: "qmd",
|
|
citations: "auto",
|
|
});
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "auto",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {});
|
|
|
|
expect(note).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not warn when remote apiKey is configured for explicit provider", async () => {
|
|
await expectNoWarningWithConfiguredRemoteApiKey("openai");
|
|
});
|
|
|
|
it("does not warn in auto mode when remote apiKey is configured", async () => {
|
|
await expectNoWarningWithConfiguredRemoteApiKey("auto");
|
|
});
|
|
|
|
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("openclaw configure --section model");
|
|
});
|
|
});
|
|
|
|
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([]);
|
|
});
|
|
});
|